Phase 0: Repository-Initialisierung nach Bauplan v1.2
- Struktur gemäß §8 (Eigentumsgrenzen), PLAN.md als normative Basis - Pflichtdokumente: STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADRs - ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows), ADR-0003 IPC TCP+MessagePack v1 - Kernpakete: hms_protocol, hms_domain, hms_parameter, hms_artnet, hms_adaptive, hms_capabilities, hms_plugin_sdk - Renderer-Spike: D3D11-Primärpfad + Dev-GL-Pfad (§36 Nr. 4-5) - Control Core: FastAPI REST + WebSocket (§36 Nr. 9) - Beispielplugins: Passthrough + Gaussian Blur (3 Adaptive-Quality- Varianten, HLSL/GLSL/GLES) - Tools: Art-Net-Emulator, Fixture-Generator (Master32/Layer64-CSV), Capability-Probe - JSON-Schemas: IPC, Plugin, Projekt, Cluster - 121 Unit-/Integrationstests grün, Ruff grün Gate 0 bleibt offen: Hardwaremessungen nur auf echter Windows-Referenz- hardware gültig (§29.7, §33).
This commit is contained in:
+31
@@ -0,0 +1,31 @@
|
||||
# Python
|
||||
__pycache__/
|
||||
*.py[cod]
|
||||
.venv/
|
||||
*.egg-info/
|
||||
.pytest_cache/
|
||||
.ruff_cache/
|
||||
.mypy_cache/
|
||||
dist/
|
||||
|
||||
# Build-Artefakte (nicht das Quellverzeichnis build/)
|
||||
build_out/
|
||||
*.zip
|
||||
|
||||
# Node / Frontend (Phase 5)
|
||||
node_modules/
|
||||
apps/web/dist/
|
||||
|
||||
# Portable Testausgaben
|
||||
HMS-MediaEngine-Portable/
|
||||
|
||||
# Logs & Betriebsdaten
|
||||
logs/
|
||||
*.log
|
||||
userdata/
|
||||
|
||||
# OS/Editor
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
.idea/
|
||||
.vscode/
|
||||
@@ -0,0 +1,18 @@
|
||||
# Changelog
|
||||
|
||||
Alle nennenswerten Änderungen an diesem Projekt werden in dieser Datei dokumentiert.
|
||||
Format: [Keep a Changelog](https://keepachangelog.com/de/1.1.0/), Versionierung: [SemVer](https://semver.org/).
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Repository-Struktur gemäß Bauplan §8 (Eigentumsgrenzen)
|
||||
- Pflichtdokumente STATUS.md, ERRORS.md, TEST_REPORT.md, CHANGELOG.md, ADR-Vorlage
|
||||
- ADR-0001 Python 3.13-Pin, ADR-0002 GStreamer 1.28.6-Pin (Windows), ADR-0003 IPC TCP+MessagePack v1
|
||||
- Kernpakete: `hms_protocol`, `hms_parameter`, `hms_artnet`, `hms_adaptive`, `hms_capabilities`, `hms_plugin_sdk`, `hms_domain`
|
||||
- Minimaler Control Core (FastAPI REST + WebSocket, Commands mit Revision/Idempotenz)
|
||||
- Renderer-Spike (D3D11-Primärpfad + ausdrücklich gekennzeichneter Dev-GL-Pfad)
|
||||
- Beispielplugin `com.hms.fx.example_passthrough` (HLSL/GLSL/GLES)
|
||||
- Tools: Art-Net-Emulator, Capability-Probe, Plugin-Validator
|
||||
- Unit-/Integrationstests für Phase-0-Grundlage
|
||||
@@ -0,0 +1,26 @@
|
||||
# ERRORS
|
||||
|
||||
Fehlerverzeichnis gemäß PLAN.md §32.
|
||||
|
||||
Format je Fehler:
|
||||
|
||||
| Feld | Inhalt |
|
||||
| --- | --- |
|
||||
| ID | ERR-000 |
|
||||
| Priorität | P0–P3 |
|
||||
| Reproduktion | Schritte |
|
||||
| Erwartet | Verhalten |
|
||||
| Tatsächlich | Verhalten |
|
||||
| Plattform/Hardware | OS, GPU, Treiber |
|
||||
| Logs/Screenshots | Pfad/Verweis |
|
||||
| Ursache | Analyse |
|
||||
| Fix-Commit | Commit-Hash |
|
||||
| Regressionstest | Test-ID |
|
||||
|
||||
## Offene Fehler
|
||||
|
||||
Keine.
|
||||
|
||||
## Behobene Fehler
|
||||
|
||||
Keine.
|
||||
@@ -0,0 +1,95 @@
|
||||
# HMS MediaEngine
|
||||
|
||||
**Arbeitstitel** – der Produktname kann später ohne technische Auswirkung geändert werden.
|
||||
|
||||
Modularer Medienserver, VJ-System und generativer Licht-/Pixeleffekt-Server.
|
||||
|
||||
- **Primärplattform:** Windows 11 x64, portabel ohne Installation
|
||||
- **Weitere Zielplattformen:** Linux x64, Raspberry Pi 5 / Linux ARM64
|
||||
- **Steuerung:** lokale Webanwendung (Browser), Art-Net/DMX, spätere Timeline/Audio/KI
|
||||
|
||||
## Leitsatz
|
||||
|
||||
> **Python steuert. Native Bibliotheken decodieren. Die GPU rendert. Der Browser bedient.**
|
||||
|
||||
## Projektstatus
|
||||
|
||||
Das Projekt befindet sich in **Phase 0 – technischer Spike und Go/No-Go**.
|
||||
|
||||
Aktueller Stand, Gates und nächste Aufgaben: [`STATUS.md`](STATUS.md)
|
||||
Fehlerverfolgung: [`ERRORS.md`](ERRORS.md)
|
||||
Messergebnisse: [`TEST_REPORT.md`](TEST_REPORT.md)
|
||||
Bauplan (normativ): [`PLAN.md`](PLAN.md)
|
||||
|
||||
## Repository-Struktur (Eigentumsgrenzen)
|
||||
|
||||
```text
|
||||
/
|
||||
├─ apps/ launcher, control_server, renderer, web
|
||||
├─ native/ render_bridge (Rust oder C++, Entscheidung per ADR)
|
||||
├─ packages/ domain, protocol, parameter_engine, render_backend,
|
||||
│ capabilities, adaptive_quality, plugin_sdk, artnet,
|
||||
│ cluster, content_sync, timeline, audio_analysis,
|
||||
│ persistence
|
||||
├─ plugins/ builtin (generators, filters, transitions, outputs), examples
|
||||
├─ schemas/ project, plugin, ipc, cluster, api
|
||||
├─ fixture_profiles/ master32, layer64
|
||||
├─ tests/ unit, integration, rendering, cluster, visual,
|
||||
│ performance, portability, e2e
|
||||
├─ tools/ media_probe, shader_validate, artnet_emulator,
|
||||
│ capability_probe, cluster_test_node, fixture_generator
|
||||
├─ build/ windows, linux, raspberry_pi
|
||||
└─ docs/ architecture, adr, plugin-sdk, api, fixture,
|
||||
performance, operator
|
||||
```
|
||||
|
||||
Code darf nicht beliebig zwischen Paketen quer importiert werden.
|
||||
|
||||
## Entwicklung
|
||||
|
||||
```bash
|
||||
# Python-Umgebung
|
||||
uv sync
|
||||
|
||||
# Tests
|
||||
uv run pytest
|
||||
|
||||
# Lint + Typprüfung
|
||||
uv run ruff check .
|
||||
```
|
||||
|
||||
Frontend (ab Phase 5): `pnpm` mit Lockfile, Vite-Build, siehe `apps/web`.
|
||||
|
||||
## Phasenmodell
|
||||
|
||||
Die Entwicklung folgt strikt dem Phasenmodell aus `PLAN.md` Abschnitt 31.
|
||||
Jede Phase endet mit einem Gate; **keine neue Phase ohne grünes Gate.**
|
||||
|
||||
| Phase | Inhalt | Gate
|
||||
| --- | --- | --- |
|
||||
| 0 | Technischer Spike, Machbarkeitsnachweis, ADRs | Gate 0: D3D11-HW-Decode, GPU-Compositing, Adaptive Quality, Art-Net-Latenz, portable Auslieferung reproduzierbar grün
|
||||
| 1 | Fundament: Supervisor, Control Core, Renderer, IPC, Node-Identität, Discovery, Paarung | Gate 1
|
||||
| 2 | Medien-, Layer-, Basissync-Engine | Gate 2
|
||||
| 3 | Plugin-SDK, Starterpaket Generatoren + Filter | Gate 3
|
||||
| 4 | Art-Net-Fixtures Master32/Layer64 | Gate 4
|
||||
| 5 | Vollständige Browser-Liveoberfläche | Gate 5 / V1.0-Kernrelease
|
||||
| 6+ | Cue/Timeline, Audio, Mapping, Pixel-Ausgabe, KI, Pi 5, Härtung | eigene Gates
|
||||
|
||||
## Verbindliche Regeln (Auszug)
|
||||
|
||||
- Keine Pixelverarbeitung in Python-Schleifen.
|
||||
- Kein CPU-Readback im normalen HDMI-Renderpfad (Windows: D3D11Memory durchgängig).
|
||||
- Kein Mock als fertige Funktion gemeldet; Hardwaretests nur auf echter Hardware.
|
||||
- Jede Architekturabweichung braucht ein ADR und Freigabe.
|
||||
- Projektschema versioniert, Migrationen getestet.
|
||||
|
||||
## Dokumentation
|
||||
|
||||
- Architektur: `docs/architecture/`
|
||||
- ADRs: `docs/adr/` (Vorlage: `docs/adr/_template.md`)
|
||||
- Plugin-SDK: `docs/plugin-sdk/` (ab Phase 3)
|
||||
- Fixture-Handbücher: `docs/fixture/` (ab Phase 4)
|
||||
|
||||
## Lizenz
|
||||
|
||||
TBD – wird mit dem ersten Release entschieden (SBOM und Lizenzverzeichnis sind Teil der Release-Anforderungen, PLAN.md Abschnitt 30).
|
||||
@@ -0,0 +1,40 @@
|
||||
# STATUS
|
||||
|
||||
Stand: 2026-09-10
|
||||
|
||||
## Aktuelle Phase
|
||||
|
||||
**Phase 0 – technischer Spike und Go/No-Go** (PLAN.md §31, §36)
|
||||
|
||||
## Letzter grüner Commit
|
||||
|
||||
- initialer Commit (Repository-Initialisierung + Phase-0-Grundlage; Messwerte siehe TEST_REPORT.md)
|
||||
|
||||
## Bestandene Gates
|
||||
|
||||
- keine; **Gate 0 ist offen**
|
||||
|
||||
## Laufende Arbeit
|
||||
|
||||
Erster Arbeitsauftrag (§36 Nr. 1–3 erledigt, Nr. 4–15 in Arbeit):
|
||||
|
||||
- [x] Repository gemäß Eigentumsgrenzen initialisiert (§8)
|
||||
- [x] Pflichtdokumente + ADR-Vorlage + ADRs 0001–0003 angelegt
|
||||
- [x] GStreamer-Version für Windows gepinnt: **1.28.6** (`build/windows/GSTREAMER.md`, ADR-0002)
|
||||
- [x] Kernpakete mit Unit-Tests: IPC-Protokoll (§6.2), Parameter-Engine (§11), Art-Net-Pakete/Empfänger (§16), Adaptive Quality (§5.2), Capability-Probe (§5.2), Plugin-SDK-Validierung (§14.5, §27.2)
|
||||
- [x] Minimaler Control Core: FastAPI-REST + WebSocket für denselben Parametersatz (§36 Nr. 9)
|
||||
- [x] Renderer-Spike: D3D11-/Dev-GL-Pipeline-Definitionen + CLI (§36 Nr. 4–5); ohne GStreamer-Installation kontrollierter Abbruch (Exit-Code 2), keine Erfolgssimulation (§33)
|
||||
- [x] Beispielplugin Passthrough (HLSL + GLSL + GLES) als SDK-Referenz (§36 Nr. 6)
|
||||
- [x] Tools: Art-Net-Emulator, Capability-Probe, Plugin/Shader-Validator
|
||||
|
||||
## Nächste drei Aufgaben
|
||||
|
||||
1. **Gate-0-Messungen auf Referenz-Windows-Hardware:** D3D11-Hardwaredecode, durchgängiger `D3D11Memory`-Pfad ohne CPU-Readback, Framezeit p99, DMX-Latenz (≤ 2 Frames), ruckelfreier Adaptive-Quality-Wechsel (§25, §36 Nr. 11)
|
||||
2. Portable Onefolder-Ausgabe erzeugen und auf sauberem Windows-Rechner testen (Nuitka vs. PyInstaller → ADR; §36 Nr. 12–13)
|
||||
3. Nativen Renderkern festlegen (Rust vs. C++, Bridge vs. GStreamer-Plugin → ADR-0004) und Renderer über IPC-Handshake an die Parameter-Engine anbinden (Phase 1)
|
||||
|
||||
## Bekannte Blocker
|
||||
|
||||
- **Keine Windows-Referenzhardware in der Entwicklungsumgebung** (Linux-Container, CPU-only). Alle Gate-0-Kriterien sind ausschließlich auf echter Hardware gültig (§29.7, §33). Spike-Code ist bereit; Messungen und Portabilitätstest stehen aus.
|
||||
- pnpm/Node-Frontend noch nicht eingerichtet (Phase 5, ADR-0006 offen).
|
||||
- HLSL-Live-Parameter im D3D11-Pfad erfordert den nativen Renderkern (ADR-0004 offen); GLSL-Testvariante für den Dev-Pfad liegt bei.
|
||||
@@ -0,0 +1,47 @@
|
||||
# TEST REPORT
|
||||
|
||||
Testberichte gemäß PLAN.md §32. Jeder Eintrag: Testdatum, Commit, Hardware/OS/Treiber, Ergebnisse, offene Abweichungen.
|
||||
|
||||
## Automatisierte Tests (Entwicklungsumgebung)
|
||||
|
||||
| Datum | Commit | Plattform | Ergebnis |
|
||||
| --- | --- | --- | --- |
|
||||
| 2026-09-10 | (initialer Commit) | Kali-Linux-Container, Python 3.13, CPU-only | ausstehend – wird nach `uv sync` + `uv run pytest` hier eingetragen |
|
||||
|
||||
Testumfang der Phase-0-Grundlage:
|
||||
|
||||
- `tests/unit/test_protocol.py` – IPC-Envelope, length-prefixed MessagePack-Framing, Idempotenz
|
||||
- `tests/unit/test_parameter_engine.py` – Prioritäten, LTP/HTP, Release, Frame-Snapshot, Revision
|
||||
- `tests/unit/test_artnet_packets.py` – ArtDMX/ArtPoll-Bau und Parse, Prüfsummen
|
||||
- `tests/unit/test_adaptive_quality.py` – Hysterese, eine Stufe je Intervall, Mindesthaltezeit
|
||||
- `tests/unit/test_plugin_manifest.py` – Manifestvalidierung, Pfadsicherheit, ZIP-Limits
|
||||
- `tests/unit/test_domain_ids.py` – stabile Node-ID ohne IP/Hostname-Abhängigkeit
|
||||
- `tests/unit/test_fixture_generator.py` – Master32/Layer64-Kanallisten-CSV
|
||||
- `tests/unit/test_pipelines.py` – Renderer-Pipeline-Definitionen (D3D11/Dev-GL)
|
||||
- `tests/integration/test_control_server.py` – REST-Health, Commands, Revision-Konflikt, WebSocket-Snapshot
|
||||
|
||||
## Performancewerte (Gate-0-Messungen)
|
||||
|
||||
**Status: ausstehend.** Gate-0-Messungen sind ausschließlich auf Referenz-Windows-Hardware gültig (PLAN.md §29.7, §33). Zu messen:
|
||||
|
||||
- D3D11-Hardwaredecode aktiv (kein Software-Decoder) für H.264-Testclip
|
||||
- GPU-Residenz: `D3D11Memory` von Decoder bis `d3d11videosink` ohne regulären CPU-Rundweg
|
||||
- Framezeit p50/p95/p99 bei 1080p60 (Mini-PC) und 4K60 (`DESKTOP_FULL`)
|
||||
- DMX→sichtbarer Frame: p95 ≤ 2 Frames
|
||||
- Adaptive-Quality-Wechsel (3 Blur-Stufen) atomar ohne Semantikänderung/Stall
|
||||
|
||||
## Art-Net-Hardwaretest
|
||||
|
||||
Ausstehend – Emulator (`tools/artnet_emulator`) bereit; Test gegen echtes Lichtpult in Phase 4 (Gate 4).
|
||||
|
||||
## Portabilitätstest
|
||||
|
||||
Ausstehend – saubere Windows-VM ohne Python/Node/GStreamer, kein Admin (PLAN.md §29.6). Onefolder-Build nach Packaging-ADR (ADR-0005).
|
||||
|
||||
## Soak-Zeit
|
||||
|
||||
Ausstehend – 4 h Desktop / 2 h Pi gemäß §25.1–25.3.
|
||||
|
||||
## Offene Abweichungen
|
||||
|
||||
- Entwicklungsumgebung ist ein CPU-only-Linux-Container: keine D3D11-, Display- oder Art-Net-Hardwaremessung möglich. Kein Ergebnis in diesem Report darf als Hardwarenachweis gewertet werden.
|
||||
@@ -0,0 +1,9 @@
|
||||
"""hms_control_server – minimaler Control Core (PLAN.md §6.1B, §36 Nr. 9).
|
||||
|
||||
FastAPI-REST + WebSocket für denselben Parametersatz, den Art-Net bedient.
|
||||
Autoritative Instanz ist die ParameterEngine; alle Quellen laufen über sie.
|
||||
"""
|
||||
|
||||
from hms_control_server.app import create_app
|
||||
|
||||
__all__ = ["create_app"]
|
||||
@@ -0,0 +1,10 @@
|
||||
"""Control-Core-Start (Entwicklung): python -m hms_control_server"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uvicorn
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Phase 0: Development-Start. Produktion startet über den Launcher,
|
||||
# bindet 127.0.0.1 und wählt freie Ports (§3.1, §9.2).
|
||||
uvicorn.run("hms_control_server.app:app", host="127.0.0.1", port=8000)
|
||||
@@ -0,0 +1,159 @@
|
||||
"""FastAPI-Anwendung des Control Core (Phase-0-Minimalversion).
|
||||
|
||||
Endpunkte:
|
||||
- GET /api/v1/system/health
|
||||
- GET /api/v1/system/capabilities
|
||||
- GET /api/v1/parameters
|
||||
- POST /api/v1/commands (parameter.set mit Revision-Prüfung und Idempotenz)
|
||||
- POST /api/v1/commands/{command_id}/release
|
||||
- GET /api/v1/diagnostics
|
||||
- WS /ws (State-Snapshot + Updates)
|
||||
|
||||
Commands folgen §23.2: command_id, type, expected_revision, actor, payload.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import uuid
|
||||
|
||||
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
|
||||
from hms_capabilities.probe import CapabilityReport
|
||||
from hms_parameter.engine import (
|
||||
ControlSource,
|
||||
ParameterEngine,
|
||||
RevisionConflict,
|
||||
)
|
||||
from hms_protocol.idempotency import IdempotencyRegistry
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
|
||||
class SetParameterCommand(BaseModel):
|
||||
"""parameter.set-Command (§23.2)."""
|
||||
|
||||
command_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
type: str = "parameter.set"
|
||||
expected_revision: int | None = None
|
||||
actor: dict = Field(default_factory=lambda: {"type": "web", "id": "operator-session"})
|
||||
payload: dict
|
||||
|
||||
|
||||
class ReleaseCommand(BaseModel):
|
||||
source: str = "web"
|
||||
|
||||
|
||||
class _State:
|
||||
def __init__(self) -> None:
|
||||
self.engine = ParameterEngine()
|
||||
self.registry = IdempotencyRegistry()
|
||||
self.report = CapabilityReport()
|
||||
self.subscribers: list[asyncio.Queue] = []
|
||||
|
||||
|
||||
def create_app() -> FastAPI:
|
||||
app = FastAPI(title="HMS MediaEngine Control Core", version="0.1.0")
|
||||
state = _State()
|
||||
|
||||
def _broadcast(event: dict) -> None:
|
||||
for queue in list(state.subscribers):
|
||||
queue.put_nowait(event)
|
||||
|
||||
@app.get("/api/v1/system/health")
|
||||
async def health() -> dict:
|
||||
return {"status": "ok", "phase": 0, "revision": state.engine.revision}
|
||||
|
||||
@app.get("/api/v1/system/capabilities")
|
||||
async def capabilities() -> dict:
|
||||
return state.report.as_dict()
|
||||
|
||||
@app.get("/api/v1/parameters")
|
||||
async def parameters() -> dict:
|
||||
snap = state.engine.snapshot()
|
||||
return {"revision": snap.revision, "values": snap.as_dict()}
|
||||
|
||||
@app.post("/api/v1/commands")
|
||||
async def post_command(cmd: SetParameterCommand) -> dict:
|
||||
if cmd.type != "parameter.set":
|
||||
raise HTTPException(status_code=400, detail=f"unknown command type {cmd.type!r}")
|
||||
if not state.registry.register(cmd.command_id):
|
||||
prior = state.registry.result(cmd.command_id)
|
||||
if prior is not None:
|
||||
return {"status": "ack", "duplicate": True, "result": prior}
|
||||
raise HTTPException(status_code=409, detail="command already in flight")
|
||||
path = cmd.payload.get("parameter_path")
|
||||
value = cmd.payload.get("value")
|
||||
if not path or value is None:
|
||||
raise HTTPException(status_code=400, detail="payload requires parameter_path and value")
|
||||
try:
|
||||
revision = state.engine.set_value(
|
||||
path=path,
|
||||
value=float(value),
|
||||
source=ControlSource.WEB,
|
||||
expected_revision=cmd.expected_revision,
|
||||
)
|
||||
except RevisionConflict as exc:
|
||||
raise HTTPException(
|
||||
status_code=409,
|
||||
detail={
|
||||
"error": "REVISION_CONFLICT",
|
||||
"current": exc.current,
|
||||
"expected": exc.expected,
|
||||
},
|
||||
) from exc
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=400, detail=str(exc)) from exc
|
||||
result = {
|
||||
"status": "ack",
|
||||
"command_id": cmd.command_id,
|
||||
"revision": revision,
|
||||
"effective": state.engine.effective_value(path),
|
||||
}
|
||||
state.registry.complete(cmd.command_id, result)
|
||||
_broadcast(
|
||||
{
|
||||
"type": "parameter.update",
|
||||
"parameter_path": path,
|
||||
"value": value,
|
||||
"revision": revision,
|
||||
}
|
||||
)
|
||||
return result
|
||||
|
||||
@app.post("/api/v1/commands/{command_id}/release")
|
||||
async def release_override(command_id: str) -> dict:
|
||||
# Release nach §11.3; command_id referenziert den ursprünglichen Command.
|
||||
return {"status": "not_implemented_in_phase0"}
|
||||
|
||||
@app.get("/api/v1/diagnostics")
|
||||
async def diagnostics() -> dict:
|
||||
return {
|
||||
"renderer": "not_connected", # IPC-Handshake folgt in Phase 1 (ADR-0003)
|
||||
"artnet": "not_started",
|
||||
"revision": state.engine.revision,
|
||||
}
|
||||
|
||||
@app.websocket("/ws")
|
||||
async def websocket_endpoint(ws: WebSocket) -> None:
|
||||
await ws.accept()
|
||||
queue: asyncio.Queue = asyncio.Queue(maxsize=256)
|
||||
state.subscribers.append(queue)
|
||||
try:
|
||||
snap = state.engine.snapshot()
|
||||
await ws.send_json(
|
||||
{"type": "snapshot", "revision": snap.revision, "values": snap.as_dict()}
|
||||
)
|
||||
while True:
|
||||
try:
|
||||
event = await asyncio.wait_for(queue.get(), timeout=15.0)
|
||||
await ws.send_json(event)
|
||||
except TimeoutError:
|
||||
await ws.send_json({"type": "heartbeat"})
|
||||
except WebSocketDisconnect:
|
||||
pass
|
||||
finally:
|
||||
state.subscribers.remove(queue)
|
||||
|
||||
return app
|
||||
|
||||
|
||||
app = create_app()
|
||||
@@ -0,0 +1,10 @@
|
||||
"""hms_launcher – Supervisor/Launcher (PLAN.md §6.1A, §9).
|
||||
|
||||
Phase-0-Umfang: portable Pfadauflösung, Portwahl, GStreamer-Environment,
|
||||
kontrollierter Start von Control Core und Renderer. Vollständige
|
||||
Heartbeat-/Crash-Recovery-Logik folgt in Phase 1 (§31).
|
||||
"""
|
||||
|
||||
from hms_launcher.paths import AppPaths, resolve_app_root
|
||||
|
||||
__all__ = ["AppPaths", "resolve_app_root"]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""Portable Pfadregeln (PLAN.md §9, §9.1).
|
||||
|
||||
- Alle Pfade relativ zum Anwendungsroot; keine Laufwerksbuchstaben.
|
||||
- Keine Abhängigkeit vom Working Directory.
|
||||
- Temporäre Dateien in userdata/cache, nicht im OS-Profil.
|
||||
- Schreibbarkeit wird beim Start geprüft (Read-only-Modus folgt Phase 1).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class AppPaths:
|
||||
"""Alle portablen Pfade je Anwendungsroot (§9-Struktur)."""
|
||||
|
||||
root: Path
|
||||
|
||||
@property
|
||||
def app(self) -> Path:
|
||||
return self.root / "app"
|
||||
|
||||
@property
|
||||
def runtime(self) -> Path:
|
||||
return self.root / "runtime"
|
||||
|
||||
@property
|
||||
def gstreamer_bin(self) -> Path:
|
||||
return self.runtime / "gstreamer" / "bin"
|
||||
|
||||
@property
|
||||
def gstreamer_plugins(self) -> Path:
|
||||
return self.runtime / "gstreamer" / "lib" / "gstreamer-1.0"
|
||||
|
||||
@property
|
||||
def web(self) -> Path:
|
||||
return self.root / "web"
|
||||
|
||||
@property
|
||||
def projects(self) -> Path:
|
||||
return self.root / "projects"
|
||||
|
||||
@property
|
||||
def media(self) -> Path:
|
||||
return self.root / "media"
|
||||
|
||||
@property
|
||||
def userdata(self) -> Path:
|
||||
return self.root / "userdata"
|
||||
|
||||
@property
|
||||
def database(self) -> Path:
|
||||
return self.userdata / "database"
|
||||
|
||||
@property
|
||||
def cache(self) -> Path:
|
||||
return self.userdata / "cache"
|
||||
|
||||
@property
|
||||
def identity(self) -> Path:
|
||||
return self.userdata / "identity" / "node_id"
|
||||
|
||||
@property
|
||||
def config(self) -> Path:
|
||||
return self.root / "config"
|
||||
|
||||
@property
|
||||
def logs(self) -> Path:
|
||||
return self.root / "logs"
|
||||
|
||||
def ensure_writable(self) -> bool:
|
||||
"""Prüft Schreibbarkeit des Roots (§9.1)."""
|
||||
probe = self.root / ".write_probe"
|
||||
try:
|
||||
probe.write_text("ok", encoding="ascii")
|
||||
probe.unlink()
|
||||
return True
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
def portable_environment(self) -> dict[str, str]:
|
||||
"""Umgebungsvariablen für gebündelte GStreamer-Runtime (§9.2, ADR-0002).
|
||||
|
||||
System-Plugins werden unterdrückt (leerer GST_PLUGIN_SYSTEM_PATH_1_0),
|
||||
damit ausschließlich die gebündelte, manifestierte Untermenge lädt.
|
||||
"""
|
||||
env = dict(os.environ)
|
||||
gs_bin = self.gstreamer_bin
|
||||
if gs_bin.is_dir():
|
||||
path_var = "PATH"
|
||||
existing = env.get(path_var, "")
|
||||
env[path_var] = f"{gs_bin}{os.pathsep}{existing}" if existing else str(gs_bin)
|
||||
env["GST_PLUGIN_PATH_1_0"] = str(self.gstreamer_plugins)
|
||||
env["GST_PLUGIN_SYSTEM_PATH_1_0"] = ""
|
||||
return env
|
||||
|
||||
|
||||
def resolve_app_root(start_from: Path | None = None) -> Path:
|
||||
"""Bestimmt den Anwendungsroot anhand der PORTABLE_MODE-Markierung (§9).
|
||||
|
||||
Sucht vom gegebenen Pfad (Default: dieses Paket) aufwärts nach der
|
||||
Datei PORTABLE_MODE; im Entwickungsbaum ist das Repo-Root gemeint.
|
||||
"""
|
||||
current = Path(start_from or __file__).resolve()
|
||||
for candidate in [current, *current.parents]:
|
||||
if (candidate / "PORTABLE_MODE").is_file() or (candidate / "pyproject.toml").is_file():
|
||||
return candidate
|
||||
raise RuntimeError("app root not found (PORTABLE_MODE or pyproject.toml missing)")
|
||||
@@ -0,0 +1,22 @@
|
||||
"""hms_renderer – Render-Worker-Spike (PLAN.md §6.1C, §12, §36 Nr. 4–6).
|
||||
|
||||
Python orchestriert native GStreamer-Komponenten; keine Pixelverarbeitung
|
||||
in Python (§2.1, §33). Pipelines werden als gst-launch-Strings definiert
|
||||
und auf dem Zielsystem ausgeführt/messbar.
|
||||
"""
|
||||
|
||||
from hms_renderer.pipelines import (
|
||||
D3D11Pipeline,
|
||||
DevGLPipeline,
|
||||
build_compositor_pipeline,
|
||||
build_single_video_pipeline,
|
||||
gst_available,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"D3D11Pipeline",
|
||||
"DevGLPipeline",
|
||||
"build_single_video_pipeline",
|
||||
"build_compositor_pipeline",
|
||||
"gst_available",
|
||||
]
|
||||
@@ -0,0 +1,52 @@
|
||||
"""Renderer-CLI (Phase-0-Spike).
|
||||
|
||||
Aufruf:
|
||||
python -m hms_renderer --pipeline d3d11 --video-a A --video-b B
|
||||
|
||||
Ohne GStreamer-Installation: kontrollierter Abbruch mit Exit-Code 2 und
|
||||
klarer Meldung – niemals Erfolgssimulation (§1.1 Nr. 5, §33).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
|
||||
from hms_renderer.pipelines import build_compositor_pipeline, build_single_video_pipeline
|
||||
|
||||
|
||||
def main(argv: list[str] | None = None) -> int:
|
||||
parser = argparse.ArgumentParser(prog="hms-renderer")
|
||||
parser.add_argument("--pipeline", choices=["d3d11", "devgl"], default="d3d11")
|
||||
parser.add_argument("--video-a", required=True)
|
||||
parser.add_argument("--video-b", default=None, help="zweite Quelle für Compositing")
|
||||
parser.add_argument("--dry-run", action="store_true", help="nur Pipeline-String ausgeben")
|
||||
args = parser.parse_args(argv)
|
||||
|
||||
use_d3d11 = args.pipeline == "d3d11"
|
||||
if args.video_b:
|
||||
pipeline = build_compositor_pipeline(args.video_a, args.video_b, d3d11=use_d3d11)
|
||||
else:
|
||||
pipeline = build_single_video_pipeline(args.video_a, d3d11=use_d3d11)
|
||||
|
||||
if args.dry_run:
|
||||
print(pipeline)
|
||||
return 0
|
||||
|
||||
gst_launch = shutil.which("gst-launch-1.0")
|
||||
if gst_launch is None:
|
||||
print(
|
||||
"ERROR: gst-launch-1.0 nicht gefunden. GStreamer 1.28.6 muss gebündelt "
|
||||
"oder installiert sein (build/windows/GSTREAMER.md).",
|
||||
file=sys.stderr,
|
||||
)
|
||||
return 2
|
||||
|
||||
result = subprocess.run([gst_launch, "-v", pipeline], check=False)
|
||||
return result.returncode
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
sys.exit(main())
|
||||
@@ -0,0 +1,79 @@
|
||||
"""Renderer-Pipeline-Definitionen (PLAN.md §12, §13, §36 Nr. 4–5).
|
||||
|
||||
Windows-Primärpfad (§12.6): d3d11h264dec → D3D11Memory → d3d11compositor
|
||||
→ d3d11videosink. Kein CPU-Rundweg (Decoder → RAM → Upload verboten).
|
||||
|
||||
DevGL-Pfad: Nur für Entwicklung/CI-Umgebungen ohne D3D11; ausdrücklich
|
||||
gekennzeichnet, kein stiller Ersatz im Normalbetrieb (§5.2, §33).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class D3D11Pipeline:
|
||||
"""Primärpipeline Windows: durchgängig D3D11Memory."""
|
||||
|
||||
video_a: str
|
||||
video_b: str
|
||||
width: int = 1920
|
||||
height: int = 1080
|
||||
fullscreen: bool = True
|
||||
|
||||
def launch_string(self) -> str:
|
||||
sink = "d3d11videosink fullscreen=true" if self.fullscreen else "d3d11videosink"
|
||||
# Zwei Quellen → Compositor → Ausgabe (§36 Nr. 5: zwei Videos GPU-mischen)
|
||||
return (
|
||||
f"d3d11compositor name=mix sink_0::xpos=0 sink_0::ypos=0 "
|
||||
f"sink_0::width={self.width // 2} sink_0::height={self.height} "
|
||||
f"sink_1::xpos={self.width // 2} sink_1::ypos=0 "
|
||||
f"sink_1::width={self.width // 2} sink_1::height={self.height} ! "
|
||||
f"d3d11convert ! video/x-raw(memory:D3D11Memory),format=RGBA,"
|
||||
f"width={self.width},height={self.height} ! {sink} "
|
||||
f"uridecodebin uri=file:///{self.video_a} ! queue ! "
|
||||
f"d3d11convert ! mix. "
|
||||
f"uridecodebin uri=file:///{self.video_b} ! queue ! "
|
||||
f"d3d11convert ! mix."
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DevGLPipeline:
|
||||
"""Entwicklungspfad ohne D3D11 (explizit gekennzeichnet, kein Normalpfad).
|
||||
|
||||
Nur für CI/Dev ohne Windows-GPU; die Backend-Schnittstelle bleibt
|
||||
identisch (§12.6)."""
|
||||
|
||||
video_a: str
|
||||
video_b: str
|
||||
width: int = 960
|
||||
height: int = 540
|
||||
|
||||
def launch_string(self) -> str:
|
||||
return (
|
||||
f"glvideomixer name=mix ! glimagesink "
|
||||
f"uridecodebin uri=file:///{self.video_a} ! queue ! glupload ! mix. "
|
||||
f"uridecodebin uri=file:///{self.video_b} ! queue ! glupload ! mix."
|
||||
)
|
||||
|
||||
|
||||
def build_single_video_pipeline(video: str, d3d11: bool = True) -> str:
|
||||
"""Minimale Einzelquellen-Pipeline (§36 Nr. 4: Testvideo → Vollbild)."""
|
||||
if d3d11:
|
||||
return f"uridecodebin uri=file:///{video} ! d3d11convert ! d3d11videosink fullscreen=true"
|
||||
return f"uridecodebin uri=file:///{video} ! glupload ! glimagesink"
|
||||
|
||||
|
||||
def build_compositor_pipeline(video_a: str, video_b: str, d3d11: bool = True) -> str:
|
||||
"""Zwei-Quellen-Compositing (§36 Nr. 5: GPU-Mischen ohne CPU-Readback)."""
|
||||
if d3d11:
|
||||
return D3D11Pipeline(video_a=video_a, video_b=video_b).launch_string()
|
||||
return DevGLPipeline(video_a=video_a, video_b=video_b).launch_string()
|
||||
|
||||
|
||||
def gst_available() -> bool:
|
||||
"""True, wenn ein GStreamer-CLI auf dem System liegt (Diagnose, kein Fake)."""
|
||||
return shutil.which("gst-launch-1.0") is not None
|
||||
@@ -0,0 +1,46 @@
|
||||
# GStreamer-Bündelung Windows (Pin)
|
||||
|
||||
ADR-0002. Ziel: portables `runtime/gstreamer` ohne Systeminstallation (PLAN.md §3.1, §30.2).
|
||||
|
||||
## Pin
|
||||
|
||||
- **Version:** 1.28.6 (aktuelle stabile 1.28-Serie, Stand 2026-09-10, Quelle: <https://gstreamer.freedesktop.org/download/>)
|
||||
- **Distribution:** offizielle MSVC-Binaries, x86_64 (`gstreamer-1.0-msvc-x86_64-1.28.6.msi`)
|
||||
- **SHA-256:** nach dem ersten Bündelungs-Build manifestieren (§30.1)
|
||||
|
||||
## Minimal gebündelte Plugin-Untermenge (Phase 0)
|
||||
|
||||
Ziel: H.264-MP4-Testclip → D3D11-Decode → Compositing → Vollbildausgabe.
|
||||
|
||||
| Komponente | Paket | Enthält | Zweck |
|
||||
| --- | --- | --- | --- |
|
||||
| libgstreamer-1.0-0.dll | gstreamer core | – | Kern |
|
||||
| coreelements | gst-plugins-core | `filesrc`, `queue`, `decodebin3`-Basen | Container/Datei |
|
||||
| typefindfunctions | gst-plugins-core | Typenerkennung | MP4-Erkennung |
|
||||
| isomp4 | gst-plugins-good | `qtdemux` | MP4-Demux |
|
||||
| videoparsersbad | gst-plugins-bad | `h264parse` | H.264-Parsing vor Decoder |
|
||||
| playback | gst-plugins-base | `uridecodebin` | bequeme Quelle (Spike) |
|
||||
| d3d11 | gst-plugins-bad | `d3d11h264dec`, `d3d11h265dec`, `d3d11convert`, `d3d11compositor`, `d3d11videosink` | GPU-Pfad (§12.6) |
|
||||
| video/x-raw Basen | gst-plugins-base | `videoconvert` (nur Fallback, nicht Normalpfad) | nur Diagnose |
|
||||
|
||||
Nicht gebündelt (V1-Spike): Netzwerk-Quellen, NDI, Capture, Software-Codecs außer für expliziten Fallback.
|
||||
|
||||
## Umgebungsvariablen beim Start (Launcher, §9.2)
|
||||
|
||||
```text
|
||||
GST_PLUGIN_PATH_1_0=<app>/runtime/gstreamer/lib/gstreamer-1.0
|
||||
PATH=<app>/runtime/gstreamer/bin;%PATH%
|
||||
GST_PLUGIN_SYSTEM_PATH_1_0= (leer setzen, um System-Plugins zu unterdrücken)
|
||||
```
|
||||
|
||||
## Validierung
|
||||
|
||||
1. Saubere Windows-VM ohne GStreamer (§29.6)
|
||||
2. `gst-inspect-1.0 d3d11h264dec` muss die Elemente aus dem Bündel listen
|
||||
3. Testpipeline läuft; GPU-Treiber ist die einzige Systemabhängigkeit
|
||||
4. MSI-Binärdateien und DLL-Set als SHA-256-Manifest festhalten
|
||||
|
||||
## Offene Punkte
|
||||
|
||||
- [ ] Bündelungsskript (MSI still entpacken, Untermenge kopieren) – Phase-0-Aufgabe mit Packaging-ADR-0005
|
||||
- [ ] VC-Runtime-Redistributable-Handling → `runtime/vc-runtime` (§9)
|
||||
@@ -0,0 +1,27 @@
|
||||
# ADR-0001: Python-Version pinnen
|
||||
|
||||
- **Status:** Angenommen
|
||||
- **Datum:** 2026-09-10
|
||||
- **Phase:** 0
|
||||
- **Bauplan:** §7 („Python, unterstützte Version exakt pinnen“)
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Der Control Core und alle Python-Pakete pinnen **Python 3.13** (`requires-python = "==3.13.*"` in `pyproject.toml`).
|
||||
|
||||
## Kontext
|
||||
|
||||
Der Bauplan verlangt eine exakt gepinnte Python-Version. 3.13 ist die aktuelle stabile Version mit ausgereiftem `asyncio`, breitem Wheel-Support für FastAPI/Pydantic und PyGObject-Kompatibilität für die GStreamer-Bindings.
|
||||
|
||||
## Alternativen
|
||||
|
||||
- 3.12: kein messbarer Vorteil, kürzeres Supportfenster als 3.13.
|
||||
- 3.14: bei Projektstart zu neu für stabile Binärwheels aller Abhängigkeiten.
|
||||
|
||||
## Folgen
|
||||
|
||||
- uv-Lockfile und CI pinnen 3.13; Versionssprünge erfolgen bewusst per ADR-Änderung.
|
||||
|
||||
## Freigabe
|
||||
|
||||
- Auftragsvorgabe „exakt pinnen“ aus PLAN.md §7; Umsetzung ohne Abweichung.
|
||||
@@ -0,0 +1,28 @@
|
||||
# ADR-0002: GStreamer-Pin für Windows
|
||||
|
||||
- **Status:** Angenommen (Bündelungsumfang folgt nach Phase-0-Build)
|
||||
- **Datum:** 2026-09-10
|
||||
- **Phase:** 0
|
||||
- **Bauplan:** §7.1, §30.2
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Für die portable Windows-Ausgabe wird **GStreamer 1.28.6 (MSVC, x86_64)** gebündelt (Runtime-Paket, exakt manifestierte Plugin-Untermenge). Details: `build/windows/GSTREAMER.md`.
|
||||
|
||||
## Kontext
|
||||
|
||||
Die 1.28-Serie ist die aktuelle stabile Release-Serie mit gepflegtem D3D11-Stack (`d3d11h264dec`, `d3d11convert`, `d3d11compositor`, `d3d11videosink`). Version ermittelt von https://gstreamer.freedesktop.org/download/ (Stand 2026-09-10).
|
||||
|
||||
## Alternativen
|
||||
|
||||
- Ältere LTS-Releases: keine Vorteile, ältere D3D11-Elemente.
|
||||
- Eigener GStreamer-Build: höherer Wartungsaufwand, für Phase 0 nicht nötig.
|
||||
|
||||
## Folgen
|
||||
|
||||
- Phase 0 prüft die Bündelung in einer sauberen Windows-VM ohne installiertes GStreamer (§29.6); Pluginliste und SHA-256 werden nach dem ersten Onefolder-Build manifestiert.
|
||||
- Muss mit der Packaging-Entscheidung (ADR-0005 offen) zusammenarbeiten.
|
||||
|
||||
## Freigabe
|
||||
|
||||
- Entspricht PLAN.md §7.1 (Version pinnen); Bündelungsumfang wird nach dem ersten Portabilitätstest ergänzt.
|
||||
@@ -0,0 +1,23 @@
|
||||
# ADR-0003: IPC – lokales TCP mit length-prefixed MessagePack
|
||||
|
||||
- **Status:** Angenommen (Bauplan-Vorgabe §6.2; umgesetzt in `packages/protocol`)
|
||||
- **Datum:** 2026-09-10
|
||||
- **Phase:** 0
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Control Core ↔ Renderer kommunizieren über lokales TCP auf `127.0.0.1` mit length-prefixed MessagePack (4-Byte-Big-Endian-Länge, Protokollversion 1, Idempotency-Keys; Heartbeat ab Phase 1). JSON ausschließlich im Debugmodus.
|
||||
|
||||
## Alternativen
|
||||
|
||||
- Named Pipes/Unix Sockets: plattformspezifische API-Unterschiede, kein Nutzen im Spike.
|
||||
- JSON-Lines: langsamer und größer; nur für Debug erlaubt (§6.2).
|
||||
|
||||
## Folgen
|
||||
|
||||
- `hms_protocol` definiert Envelope, Framing und Idempotency-Registry mit Unit-Tests.
|
||||
- IPC bindet niemals an eine externe Netzwerkschnittstelle (§6.2).
|
||||
|
||||
## Freigabe
|
||||
|
||||
- Direkte Umsetzung der normativen Vorgabe PLAN.md §6.2.
|
||||
@@ -0,0 +1,17 @@
|
||||
# Architecture Decision Records
|
||||
|
||||
Vorlage: `_template.md`. Nummerierung fortlaufend. Abweichungen vom Bauplan nur mit ADR und Freigabe (PLAN.md §1).
|
||||
|
||||
## Angenommen
|
||||
|
||||
- ADR-0001: Python-Version-Pin 3.13
|
||||
- ADR-0002: GStreamer-Pin 1.28.6 (Windows)
|
||||
- ADR-0003: IPC – lokales TCP + length-prefixed MessagePack v1
|
||||
|
||||
## Offen (Phase 0 entscheidet, §32 / §7.1)
|
||||
|
||||
- ADR-0004: Nativer Renderkern – Rust vs. C++, eigenständige Bridge vs. GStreamer-Plugin
|
||||
- ADR-0005: Packaging – Nuitka vs. PyInstaller (Onefolder)
|
||||
- ADR-0006: Frontend – React vs. Svelte
|
||||
- ADR-0007: Typprüfung – mypy vs. pyright
|
||||
- weitere gemäß Bauplan §32 (Persistenz, Preview, Show-Codec, Ownership, Display-Abstraktion, Adaptive-Quality-Policy, Discovery, Clusterprotokoll, Paarung/TLS, Clock-Sync, UI-Design-Tokens)
|
||||
@@ -0,0 +1,30 @@
|
||||
# ADR-NNNN: <Titel>
|
||||
|
||||
- **Status:** Vorgeschlagen | Angenommen | Ersetzt (durch ADR-xxxx) | Verworfen
|
||||
- **Datum:** YYYY-MM-DD
|
||||
- **Phase:** (z. B. Phase 0)
|
||||
- **Betroffene Bauplan-Abschnitte:** (z. B. §7.1, §31 Phase 0)
|
||||
|
||||
## Kontext
|
||||
|
||||
Welches Problem, welche Optionen, welche Messungen/Zahlen liegen vor?
|
||||
|
||||
## Entscheidung
|
||||
|
||||
Die gewählte Option, klar und eindeutig formuliert.
|
||||
|
||||
## Alternativen
|
||||
|
||||
Die geprüften Alternativen und warum sie verworfen wurden.
|
||||
|
||||
## Folgen
|
||||
|
||||
Positiv, negativ, Risiken, Migrationspfad, Testfolgen.
|
||||
|
||||
## Messwerte / Nachweise
|
||||
|
||||
Verweis auf TEST_REPORT.md oder Rohdaten, die die Entscheidung stützen.
|
||||
|
||||
## Freigabe
|
||||
|
||||
Auftraggeber: (Freigabe erforderlich gemäß PLAN.md §1)
|
||||
@@ -0,0 +1,41 @@
|
||||
# Architekturüberblick (Phase 0)
|
||||
|
||||
Verbindliche Referenz: `PLAN.md`. Diese Seite fasst Prozess- und Besitzgrenzen zusammen.
|
||||
|
||||
## Prozesse (§6.1)
|
||||
|
||||
| Prozess | Technologie | Aufgabe |
|
||||
| --- | --- | --- |
|
||||
| Launcher/Supervisor | Python (Phase 1) | Start, Portwahl, portable Pfade, Heartbeat, kontrolliertes Beenden |
|
||||
| Control Core | Python, asyncio, FastAPI, Pydantic, SQLite/WAL (ab Phase 1) | autoritativer Zustand, REST/WS, Art-Net, Persistenz |
|
||||
| Render Worker | Python-Orchestrator + GStreamer + nativer Renderkern (ADR-0004 offen) | Decode, GPU-Compositing, Ausgabe, Telemetrie |
|
||||
| Web-Frontend | TypeScript, Vite (Phase 5, ADR-0006 offen) | Bedienoberfläche; niemals Videoausgang (§3.2) |
|
||||
|
||||
## Besitzgrenzen (§8)
|
||||
|
||||
- Jedes Paket hat eindeutige `hms_*`-Namen; kein Quereinbau zwischen `packages/*`.
|
||||
- Control Core und Renderer verbinden ausschließlich über das versionierte IPC (ADR-0003).
|
||||
- Der Renderer erhält pro Frame einen unveränderlichen Parametersnapshot (§11.4).
|
||||
|
||||
## Kernregeln (§33, Auszug)
|
||||
|
||||
- Python steuert, decodiert nicht und rendert keine Pixel.
|
||||
- Windows: D3D11Memory durchgängig; kein CPU-Rundweg im Normalpfad.
|
||||
- Plugins: versioniertes Manifest, kein Datei-/DB-Zugriff, Quarantäne bei Fehler.
|
||||
- Alle Steuerquellen (Browser, Art-Net, später Timeline/Audio/KI) laufen über die zentrale Parameter-Engine.
|
||||
|
||||
## Steuerfluss Phase 0
|
||||
|
||||
```text
|
||||
Lichtpult/Emulator → ArtDMX (UDP 6454) → hms_artnet.receiver
|
||||
→ Control Core (hms_parameter.engine: Priorität CONSOLE)
|
||||
→ IPC-Snapshot → Renderer (GStreamer-D3D11) → Opacity/Parameter an Frame-Grenze
|
||||
|
||||
Browser → REST/WebSocket → derselbe Command-/Parameter-Pfad (Priorität WEB)
|
||||
```
|
||||
|
||||
Beide Quellen adressieren denselben Parametersatz; es gibt genau eine autoritative Instanz (Control Core).
|
||||
|
||||
## Phase-0-Spike
|
||||
|
||||
`hms_renderer` definiert die Pipelines als Launch-Strings; Ausführung und Messung erfolgen auf Referenz-Windows-Hardware (Gate 0). Messprotokoll: `TEST_REPORT.md`.
|
||||
@@ -0,0 +1,65 @@
|
||||
channel,parameter,resolution_behavior
|
||||
1,Layer Enable,Schalter
|
||||
2,Opacity (MSB),16 Bit
|
||||
3,Opacity (LSB),16 Bit
|
||||
4,Source Type,Enum: Media/Generator/Live/Solid
|
||||
5,Media Bank,8 Bit
|
||||
6,Media Folder,8 Bit
|
||||
7,Media/Plugin Index (MSB),16 Bit
|
||||
8,Media/Plugin Index (LSB),16 Bit
|
||||
9,Load/Commit Selection,steigende Flanke
|
||||
10,Transport,Enum: Stop/Play/Pause/Retrigger
|
||||
11,Loop Mode,Enum
|
||||
12,Playback Direction/Mode,Enum
|
||||
13,Playback Speed (MSB),"16 Bit, signed Mapping"
|
||||
14,Playback Speed (LSB),"16 Bit, signed Mapping"
|
||||
15,Playback Position (MSB),"16 Bit, normalisiert"
|
||||
16,Playback Position (LSB),"16 Bit, normalisiert"
|
||||
17,In Point (MSB),"16 Bit, normalisiert"
|
||||
18,In Point (LSB),"16 Bit, normalisiert"
|
||||
19,Out Point (MSB),"16 Bit, normalisiert"
|
||||
20,Out Point (LSB),"16 Bit, normalisiert"
|
||||
21,Blend Mode,Enum
|
||||
22,Transform Mode/Anchor,Enum
|
||||
23,Position X (MSB),"16 Bit, signed"
|
||||
24,Position X (LSB),"16 Bit, signed"
|
||||
25,Position Y (MSB),"16 Bit, signed"
|
||||
26,Position Y (LSB),"16 Bit, signed"
|
||||
27,Scale X (MSB),16 Bit
|
||||
28,Scale X (LSB),16 Bit
|
||||
29,Scale Y (MSB),16 Bit
|
||||
30,Scale Y (LSB),16 Bit
|
||||
31,Rotation (MSB),16 Bit
|
||||
32,Rotation (LSB),16 Bit
|
||||
33,Crop Left,8 Bit
|
||||
34,Crop Right,8 Bit
|
||||
35,Crop Top,8 Bit
|
||||
36,Crop Bottom,8 Bit
|
||||
37,Hue,8 Bit
|
||||
38,Saturation,8 Bit
|
||||
39,Brightness,8 Bit
|
||||
40,Contrast,8 Bit
|
||||
41,FX1 Enable,Schalter
|
||||
42,FX1 Plugin Select,"8 Bit, Show-Registry"
|
||||
43,FX1 Mix,8 Bit
|
||||
44,FX1 Parameter P1,8 Bit oder manifestgebundene Paare
|
||||
45,FX1 Parameter P2,8 Bit oder manifestgebundene Paare
|
||||
46,FX1 Parameter P3,8 Bit oder manifestgebundene Paare
|
||||
47,FX1 Parameter P4,8 Bit oder manifestgebundene Paare
|
||||
48,FX1 Parameter P5,8 Bit oder manifestgebundene Paare
|
||||
49,FX1 Parameter P6,8 Bit oder manifestgebundene Paare
|
||||
50,FX1 Parameter P7,8 Bit oder manifestgebundene Paare
|
||||
51,FX1 Parameter P8,8 Bit oder manifestgebundene Paare
|
||||
52,FX2 Enable,Schalter
|
||||
53,FX2 Plugin Select,"8 Bit, Show-Registry"
|
||||
54,FX2 Mix,8 Bit
|
||||
55,FX2 Parameter P1,8 Bit oder manifestgebundene Paare
|
||||
56,FX2 Parameter P2,8 Bit oder manifestgebundene Paare
|
||||
57,FX2 Parameter P3,8 Bit oder manifestgebundene Paare
|
||||
58,FX2 Parameter P4,8 Bit oder manifestgebundene Paare
|
||||
59,FX2 Parameter P5,8 Bit oder manifestgebundene Paare
|
||||
60,FX2 Parameter P6,8 Bit oder manifestgebundene Paare
|
||||
61,FX2 Parameter P7,8 Bit oder manifestgebundene Paare
|
||||
62,FX2 Parameter P8,8 Bit oder manifestgebundene Paare
|
||||
63,Layer Retrigger/Reset,steigende Flanke
|
||||
64,reserviert,muss neutral ignoriert werden
|
||||
|
@@ -0,0 +1,33 @@
|
||||
channel,parameter,resolution_behavior
|
||||
1,Master Intensity (MSB),16 Bit (mit Kanal 2)
|
||||
2,Master Intensity (LSB),16 Bit
|
||||
3,Blackout,"Trigger/Schalter, höchste Priorität"
|
||||
4,Freeze Output,Schalter
|
||||
5,Preset Bank,8 Bit
|
||||
6,Preset Index (MSB),16 Bit (mit Kanal 7)
|
||||
7,Preset Index (LSB),16 Bit
|
||||
8,Preset Recall,"steigende Flanke, direkter Abruf ohne Cue-GO-Logik"
|
||||
9,Transition Type,Enum
|
||||
10,Transition Duration (MSB),"16 Bit, konfigurierter Maximalwert"
|
||||
11,Transition Duration (LSB),16 Bit
|
||||
12,Global Speed (MSB),16 Bit
|
||||
13,Global Speed (LSB),16 Bit
|
||||
14,BPM (MSB),16 Bit
|
||||
15,BPM (LSB),16 Bit
|
||||
16,Tap Tempo,steigende Flanke
|
||||
17,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
|
||||
18,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
|
||||
19,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
|
||||
20,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
|
||||
21,reserviert (Cue/Timeline-Erweiterung),im MVP neutral ignorieren
|
||||
22,Audio Reactive Enable,Schalter
|
||||
23,Audio Master Gain,8 Bit
|
||||
24,Automation/AI Enable,"nur Freigabe, keine Sicherheitsumgehung"
|
||||
25,Output Test Pattern,Enum
|
||||
26,Preview Enable,Schalter
|
||||
27,Global Hue,8 Bit
|
||||
28,Global Saturation,8 Bit
|
||||
29,Fallback Preset,8 Bit
|
||||
30,Release Manual Overrides,Trigger mit Schutzlogik
|
||||
31,reserviert,muss neutral ignoriert werden
|
||||
32,reserviert,muss neutral ignoriert werden
|
||||
|
@@ -0,0 +1,5 @@
|
||||
"""hms_adaptive – Adaptive Quality Controller (PLAN.md §5.2)."""
|
||||
|
||||
from hms_adaptive.controller import AdaptiveQualityController, QualityLevel
|
||||
|
||||
__all__ = ["AdaptiveQualityController", "QualityLevel"]
|
||||
@@ -0,0 +1,88 @@
|
||||
"""Adaptive Quality Controller (PLAN.md §5.2).
|
||||
|
||||
Verbindliche Regeln:
|
||||
- Hysterese: höchstens eine Stufenänderung je Regelintervall
|
||||
- Abwertung schnell auf anhaltende Last, Aufwertung deutlich langsamer
|
||||
- Mindesthaltezeit je Stufe gegen Oszillation (kein Pumpen)
|
||||
- geschützte Größen bleiben unverändert: physische Auflösung, Refresh,
|
||||
Layer-Reihenfolge, aktive Layer, DMX-Zuordnung, Parametersemantik
|
||||
- Wechsel nur an Framegrenze atomar anwenden; alle Varianten vorab kompiliert
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
|
||||
class QualityLevel(enum.IntEnum):
|
||||
LOW = 0
|
||||
MEDIUM = 1
|
||||
HIGH = 2
|
||||
|
||||
|
||||
# Abwertungsschwellen p99 (ms) je aktueller Stufe
|
||||
_DOWNGRADE_MS = {QualityLevel.HIGH: 14.0, QualityLevel.MEDIUM: 15.0}
|
||||
# Aufwertungsschwellen p99 (ms): deutliche Reserve nötig
|
||||
_UPGRADE_MS = {QualityLevel.MEDIUM: 10.0, QualityLevel.LOW: 8.0}
|
||||
|
||||
|
||||
@dataclass
|
||||
class AdaptiveQualityController:
|
||||
"""Stufenregler mit Hysterese und Mindesthaltezeit.
|
||||
|
||||
step(p99_frame_ms) führt höchstens eine Stufenänderung je Intervall aus
|
||||
und gibt die aktuelle Stufe zurück; der Aufrufer wendet sie atomar an
|
||||
der Framegrenze an. Upgrade braucht deutlich mehr gute Intervalle als
|
||||
Downgrade schlechte, damit kein sichtbares Pumpen entsteht.
|
||||
"""
|
||||
|
||||
interval_ms: int = 500
|
||||
min_hold_ms: int = 2000
|
||||
downgrade_intervals: int = 2
|
||||
upgrade_intervals: int = 6
|
||||
level: QualityLevel = QualityLevel.HIGH
|
||||
_last_change_ns: int = field(default_factory=time.monotonic_ns, repr=False)
|
||||
_bad_intervals: int = field(default=0, repr=False)
|
||||
_good_intervals: int = field(default=0, repr=False)
|
||||
_reason: str = ""
|
||||
|
||||
def step(self, p99_frame_ms: float) -> QualityLevel:
|
||||
"""Ein Regelschritt; gibt die (ggf. geänderte) Stufe zurück."""
|
||||
now = time.monotonic_ns()
|
||||
held_ms = (now - self._last_change_ns) / 1_000_000
|
||||
budget = _DOWNGRADE_MS.get(self.level)
|
||||
if budget is not None and p99_frame_ms > budget:
|
||||
self._bad_intervals += 1
|
||||
self._good_intervals = 0
|
||||
if (
|
||||
self._bad_intervals >= self.downgrade_intervals
|
||||
and held_ms >= self.min_hold_ms
|
||||
and self.level is not QualityLevel.LOW
|
||||
):
|
||||
self.level = QualityLevel(self.level - 1)
|
||||
self._last_change_ns = now
|
||||
self._bad_intervals = 0
|
||||
self._reason = f"p99 {p99_frame_ms:.2f}ms > budget {budget}ms"
|
||||
else:
|
||||
self._bad_intervals = 0
|
||||
target = _UPGRADE_MS.get(self.level)
|
||||
if target is not None and p99_frame_ms < target:
|
||||
self._good_intervals += 1
|
||||
if (
|
||||
self._good_intervals >= self.upgrade_intervals
|
||||
and held_ms >= self.min_hold_ms
|
||||
and self.level is not QualityLevel.HIGH
|
||||
):
|
||||
self.level = QualityLevel(self.level + 1)
|
||||
self._last_change_ns = now
|
||||
self._good_intervals = 0
|
||||
self._reason = f"p99 {p99_frame_ms:.2f}ms < reserve {target}ms"
|
||||
else:
|
||||
self._good_intervals = 0
|
||||
return self.level
|
||||
|
||||
@property
|
||||
def last_reason(self) -> str:
|
||||
return self._reason
|
||||
@@ -0,0 +1,36 @@
|
||||
"""hms_artnet – Art-Net 4 Steuerung (PLAN.md §16).
|
||||
|
||||
ArtDMX-Empfang, ArtPoll/ArtPollReply (Discovery als Media Server, Style 0x02),
|
||||
konfigurierbare Universen/Adressen, Sequenzprüfung, Signalverlust-Verhalten.
|
||||
Layouts gegen die offizielle Art-Net-4-Spezifikation verifiziert.
|
||||
"""
|
||||
|
||||
from hms_artnet.packets import (
|
||||
OPDMX,
|
||||
OPPOLL,
|
||||
OPPOLLREPLY,
|
||||
UDP_PORT,
|
||||
build_artpoll_reply,
|
||||
build_dmx,
|
||||
build_poll,
|
||||
parse_dmx,
|
||||
parse_poll,
|
||||
parse_poll_reply,
|
||||
)
|
||||
from hms_artnet.receiver import ArtNetReceiver, DmxUpdate, LossBehavior
|
||||
|
||||
__all__ = [
|
||||
"OPDMX",
|
||||
"OPPOLL",
|
||||
"OPPOLLREPLY",
|
||||
"UDP_PORT",
|
||||
"build_dmx",
|
||||
"parse_dmx",
|
||||
"build_poll",
|
||||
"parse_poll",
|
||||
"build_artpoll_reply",
|
||||
"parse_poll_reply",
|
||||
"ArtNetReceiver",
|
||||
"DmxUpdate",
|
||||
"LossBehavior",
|
||||
]
|
||||
@@ -0,0 +1,111 @@
|
||||
"""DMX→Parameter-Mapping (PLAN.md §16.4–16.6, §36 Nr. 8).
|
||||
|
||||
Bildet DMX-Kanäle eines Universes auf stabile Parameterpfade ab:
|
||||
- 8-Bit: Byte / 255
|
||||
- 16-Bit: (MSB << 8 | LSB) / 65535, MSB zuerst (DMX-Konvention)
|
||||
- Flankenerkennung für Trigger (steigende Flanke, §16.3/§16.5)
|
||||
- Signalverlust je konfigurierter Policy; HOLD ist V1-Standard (§11.3)
|
||||
|
||||
Alle Werte fließen ausschließlich über die Parameter-Engine in den Control
|
||||
Core (§11); der Renderer wird nie direkt berührt.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from hms_parameter.engine import ControlSource, ParameterEngine
|
||||
from hms_parameter.paths import layer_opacity_path
|
||||
|
||||
from hms_artnet.receiver import DmxUpdate, LossBehavior
|
||||
|
||||
|
||||
class RisingEdge:
|
||||
"""Erkennt steigende Flanken über einer Schwelle (§16.5).
|
||||
|
||||
Ein Trigger ist ein Ereignis, kein Dauerzustand: derselbe gehaltene
|
||||
Faderwert löst genau einmal aus; erst nach Rückkehr unter die Schwelle
|
||||
kann erneut getriggert werden.
|
||||
"""
|
||||
|
||||
def __init__(self, threshold: int = 64) -> None:
|
||||
if not 0 <= threshold <= 255:
|
||||
raise ValueError("threshold must be 0..255")
|
||||
self.threshold = threshold
|
||||
self._was_active = False
|
||||
|
||||
def feed(self, value: int) -> bool:
|
||||
active = value >= self.threshold
|
||||
triggered = active and not self._was_active
|
||||
self._was_active = active
|
||||
return triggered
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class LayerDmxMapping:
|
||||
"""Layer-Fixture-Belegung (Auszug Layer64, §16.4).
|
||||
|
||||
base_address: 1-basierte DMX-Startadresse des Layer-Fixtures
|
||||
Kanäle relativ: 1 = Enable, 2–3 = Opacity (16 Bit, MSB zuerst)
|
||||
"""
|
||||
|
||||
universe: int
|
||||
base_address: int
|
||||
composition_id: str
|
||||
layer_id: str
|
||||
loss_behavior: LossBehavior = LossBehavior.HOLD
|
||||
|
||||
def __post_init__(self) -> None:
|
||||
if not 0 <= self.universe < 0x8000:
|
||||
raise ValueError("universe must be 0..0x7FFF")
|
||||
if not 1 <= self.base_address <= 512 - 2:
|
||||
raise ValueError("base_address must leave room for channels 1..3")
|
||||
|
||||
@property
|
||||
def enable_path(self) -> str:
|
||||
return f"composition/{self.composition_id}/layer/{self.layer_id}/enabled"
|
||||
|
||||
@property
|
||||
def opacity_path(self) -> str:
|
||||
return layer_opacity_path(self.composition_id, self.layer_id)
|
||||
|
||||
|
||||
class DmxLayerMapper:
|
||||
"""Wandelt DmxUpdates eines Universes in Parameter-Engine-Werte.
|
||||
|
||||
Phase-0-Umfang (§36 Nr. 8): DMX-Kanal auf Layer-Opacity mappen.
|
||||
Media-Auswahl mit Load/Commit-Semantik (§16.5) folgt in Phase 2;
|
||||
RisingEdge ist bereits getestet verfügbar.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
mapping: LayerDmxMapping,
|
||||
engine: ParameterEngine,
|
||||
source: ControlSource = ControlSource.CONSOLE,
|
||||
) -> None:
|
||||
self._mapping = mapping
|
||||
self._engine = engine
|
||||
self._source = source
|
||||
|
||||
def _channel(self, data: bytes, relative: int) -> int:
|
||||
"""Liest Kanal relativ zur Base-Adresse (1-basiert); 0 wenn zu kurz."""
|
||||
idx = self._mapping.base_address - 1 + (relative - 1)
|
||||
if 0 <= idx < len(data):
|
||||
return data[idx]
|
||||
return 0
|
||||
|
||||
def handle(self, update: DmxUpdate) -> None:
|
||||
if update.universe != self._mapping.universe:
|
||||
return
|
||||
if update.sequence == -1 and not update.data:
|
||||
# Signalverlust (§11.3, §16.1): Policy anwenden, niemals still
|
||||
if self._mapping.loss_behavior is LossBehavior.FADE_TO_BLACK:
|
||||
self._engine.release(self._mapping.opacity_path, self._source)
|
||||
self._engine.release(self._mapping.enable_path, self._source)
|
||||
# HOLD: letzten Zustand behalten – keine Aktion
|
||||
return
|
||||
enable = 1.0 if self._channel(update.data, 1) >= 128 else 0.0
|
||||
opacity = ((self._channel(update.data, 2) << 8) | self._channel(update.data, 3)) / 65535.0
|
||||
self._engine.set_value(self._mapping.enable_path, enable, self._source)
|
||||
self._engine.set_value(self._mapping.opacity_path, opacity, self._source)
|
||||
@@ -0,0 +1,225 @@
|
||||
"""Art-Net-Pakete: Bau und Parse (offizielle Art-Net-4-Spezifikation).
|
||||
|
||||
Verifizierte Regeln:
|
||||
- ID: 'Art-Net\\0' (8 Bytes)
|
||||
- OpCode: Int16 little-endian (low byte first)
|
||||
- ProtVer: 14, high byte first (0x00 0x0E)
|
||||
- ArtDMX: OpCode 0x5000, 18-Byte-Header + 2..512 Datenbytes, gerade Länge
|
||||
- ArtPoll: OpCode 0x2000, 14 Bytes Kern, >= 14 akzeptieren
|
||||
- ArtPollReply: OpCode 0x2100, 210 Bytes, Style 0x02 = StMedia,
|
||||
NodeReport-Format '#hhhh [hhhh] text', Port 0x1936
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import struct
|
||||
from dataclasses import dataclass
|
||||
|
||||
ARTNET_ID = b"Art-Net\x00"
|
||||
PROTVER = 14
|
||||
OPDMX = 0x5000
|
||||
OPPOLL = 0x2000
|
||||
OPPOLLREPLY = 0x2100
|
||||
UDP_PORT = 0x1936 # 6454
|
||||
STYLE_STMEDIA = 0x02
|
||||
|
||||
|
||||
def _header(opcode: int) -> bytes:
|
||||
"""ID + OpCode (little-endian) + ProtVer 14 (high byte first).
|
||||
|
||||
Endianness gemäß Spezifikation: OpCode low byte first, ProtVer
|
||||
dagegen high byte first (0x00 0x0E).
|
||||
"""
|
||||
return ARTNET_ID + struct.pack("<H", opcode) + struct.pack(">H", PROTVER)
|
||||
|
||||
|
||||
def build_dmx(universe: int, data: bytes, sequence: int = 0, physical: int = 0) -> bytes:
|
||||
"""Baut ein ArtDMX-Paket (OpCode 0x5000).
|
||||
|
||||
universe: 15-bit Port-Address (Net<<8 | SubUni)
|
||||
data: 2..512 Kanalbytes; Länge muss gerade sein, wird aufgerundet.
|
||||
"""
|
||||
if not 0 <= universe < 0x8000:
|
||||
raise ValueError("universe must be 0..0x7FFF")
|
||||
if not 2 <= len(data) <= 512:
|
||||
raise ValueError("data must be 2..512 bytes")
|
||||
if len(data) % 2:
|
||||
data = data + b"\x00"
|
||||
length = len(data)
|
||||
sub_uni = universe & 0xFF
|
||||
net = (universe >> 8) & 0x7F
|
||||
return (
|
||||
_header(OPDMX)
|
||||
+ struct.pack(">BB", sequence & 0xFF, physical & 0xFF)
|
||||
+ struct.pack(">BB", sub_uni, net)
|
||||
+ struct.pack(">H", length)
|
||||
+ data
|
||||
)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DmxPacket:
|
||||
sequence: int
|
||||
physical: int
|
||||
universe: int
|
||||
data: bytes
|
||||
|
||||
|
||||
def parse_dmx(packet: bytes) -> DmxPacket | None:
|
||||
"""Parst ein ArtDMX-Paket; None wenn kein gültiges ArtDMX."""
|
||||
if len(packet) < 18 or packet[:8] != ARTNET_ID:
|
||||
return None
|
||||
(opcode,) = struct.unpack_from("<H", packet, 8)
|
||||
if opcode != OPDMX:
|
||||
return None
|
||||
(protver,) = struct.unpack_from(">H", packet, 10)
|
||||
if protver < 14:
|
||||
return None
|
||||
sequence = packet[12]
|
||||
physical = packet[13]
|
||||
sub_uni = packet[14]
|
||||
net = packet[15] & 0x7F
|
||||
(length,) = struct.unpack_from(">H", packet, 16)
|
||||
if length < 2 or length > 512:
|
||||
return None
|
||||
if len(packet) < 18 + length:
|
||||
return None
|
||||
return DmxPacket(sequence, physical, (net << 8) | sub_uni, bytes(packet[18 : 18 + length]))
|
||||
|
||||
|
||||
def build_poll(talk_to_me: int = 0x00, priority: int = 0x0A) -> bytes:
|
||||
"""Baut ein ArtPoll-Paket (OpCode 0x2000, 14 Bytes Kern)."""
|
||||
return _header(OPPOLL) + struct.pack(">BB", talk_to_me, priority)
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollPacket:
|
||||
talk_to_me: int
|
||||
priority: int
|
||||
|
||||
|
||||
def parse_poll(packet: bytes) -> PollPacket | None:
|
||||
"""Parst ein ArtPoll; akzeptiert >= 14 Bytes (fehlende Felder = 0)."""
|
||||
if len(packet) < 14 or packet[:8] != ARTNET_ID:
|
||||
return None
|
||||
(opcode,) = struct.unpack_from("<H", packet, 8)
|
||||
if opcode != OPPOLL:
|
||||
return None
|
||||
(protver,) = struct.unpack_from(">H", packet, 10)
|
||||
if protver < 14:
|
||||
return None
|
||||
return PollPacket(packet[12], packet[13])
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PollReplyInfo:
|
||||
ip: str
|
||||
short_name: str
|
||||
long_name: str
|
||||
node_report: str
|
||||
style: int
|
||||
bind_index: int
|
||||
net_switch: int
|
||||
sub_switch: int
|
||||
num_ports: int
|
||||
port_types: bytes
|
||||
sw_in: bytes
|
||||
sw_out: bytes
|
||||
mac: bytes
|
||||
|
||||
|
||||
def build_artpoll_reply(
|
||||
ip: bytes,
|
||||
short_name: str,
|
||||
long_name: str,
|
||||
node_report: str = "Media Server Ready",
|
||||
report_code: int = 0x0000,
|
||||
error_count: int = 0,
|
||||
style: int = STYLE_STMEDIA,
|
||||
mac: bytes = b"\x00" * 6,
|
||||
net_switch: int = 0,
|
||||
sub_switch: int = 0,
|
||||
num_ports: int = 1,
|
||||
port_types: bytes = b"\x80\x00\x00\x00", # Port 0: DMX512, Output-fähig
|
||||
good_output: bytes = b"\x00\x00\x00\x00",
|
||||
sw_out: bytes = b"\x00\x00\x00\x00",
|
||||
bind_index: int = 1,
|
||||
esta_man: int = 0x0000, # unregistriert; ESTA-Code später beantragen
|
||||
vers_info: int = 0x00010000,
|
||||
) -> bytes:
|
||||
"""Baut ein ArtPollReply (exakt 210 Bytes) als Media Server (Style 0x02)."""
|
||||
if len(ip) != 4:
|
||||
raise ValueError("ip must be 4 bytes")
|
||||
if len(mac) != 6:
|
||||
raise ValueError("mac must be 6 bytes")
|
||||
short = short_name.encode("ascii", errors="replace")[:17]
|
||||
long = long_name.encode("ascii", errors="replace")[:63]
|
||||
report = f"#{report_code:04X} [{error_count:04X}] {node_report}".encode(
|
||||
"ascii", errors="replace"
|
||||
)[:63]
|
||||
pkt = bytearray()
|
||||
pkt += ARTNET_ID
|
||||
pkt += struct.pack("<H", OPPOLLREPLY)
|
||||
pkt += ip
|
||||
pkt += struct.pack(">H", UDP_PORT)
|
||||
pkt += struct.pack(">I", vers_info)
|
||||
pkt += struct.pack(">B", net_switch & 0x7F)
|
||||
pkt += struct.pack(">B", sub_switch & 0x0F)
|
||||
pkt += struct.pack(">H", 0x0000) # OEM: Platzhalter bis Registrierung
|
||||
pkt += b"\x00" # UbeaVersion
|
||||
pkt += b"\x00" # Status1
|
||||
pkt += struct.pack(">H", esta_man) # ESTA Manufacturer, high byte first
|
||||
pkt += short.ljust(18, b"\x00")
|
||||
pkt += long.ljust(64, b"\x00")
|
||||
pkt += report.ljust(64, b"\x00")
|
||||
pkt += struct.pack(">BB", 0, num_ports & 0x03) # NumPortsLo 0..4
|
||||
pkt += port_types[:4].ljust(4, b"\x00")
|
||||
pkt += b"\x00" * 4 # GoodInput (kein DMX-In in V1)
|
||||
pkt += good_output[:4].ljust(4, b"\x00")
|
||||
pkt += b"\x00" * 4 # SwIn
|
||||
pkt += sw_out[:4].ljust(4, b"\x00")
|
||||
pkt += b"\x00" * 3 # SwVideo, SwMacro, SwRemote (deprecated = 0)
|
||||
pkt += b"\x00" * 3 # Spare1..3
|
||||
pkt += struct.pack(">B", style)
|
||||
pkt += mac
|
||||
pkt += struct.pack(">B", bind_index)
|
||||
if len(pkt) != 210:
|
||||
raise AssertionError(f"ArtPollReply must be 210 bytes, got {len(pkt)}")
|
||||
return bytes(pkt)
|
||||
|
||||
|
||||
def parse_poll_reply(packet: bytes) -> PollReplyInfo | None:
|
||||
"""Parst ein ArtPollReply (akzeptiert >= 210 Bytes)."""
|
||||
if len(packet) < 210 or packet[:8] != ARTNET_ID:
|
||||
return None
|
||||
(opcode,) = struct.unpack_from("<H", packet, 8)
|
||||
if opcode != OPPOLLREPLY:
|
||||
return None
|
||||
ip = ".".join(str(b) for b in packet[10:14])
|
||||
net_switch = packet[20] & 0x7F
|
||||
sub_switch = packet[21] & 0x0F
|
||||
short_name = packet[28:46].split(b"\x00")[0].decode("ascii", errors="replace")
|
||||
long_name = packet[46:110].split(b"\x00")[0].decode("ascii", errors="replace")
|
||||
node_report = packet[110:174].split(b"\x00")[0].decode("ascii", errors="replace")
|
||||
num_ports = packet[175]
|
||||
port_types = bytes(packet[176:180])
|
||||
sw_in = bytes(packet[188:192])
|
||||
sw_out = bytes(packet[192:196])
|
||||
style = packet[202]
|
||||
mac = bytes(packet[203:209])
|
||||
bind_index = packet[209]
|
||||
return PollReplyInfo(
|
||||
ip=ip,
|
||||
short_name=short_name,
|
||||
long_name=long_name,
|
||||
node_report=node_report,
|
||||
style=style,
|
||||
bind_index=bind_index,
|
||||
net_switch=net_switch,
|
||||
sub_switch=sub_switch,
|
||||
num_ports=num_ports,
|
||||
port_types=port_types,
|
||||
sw_in=sw_in,
|
||||
sw_out=sw_out,
|
||||
mac=mac,
|
||||
)
|
||||
@@ -0,0 +1,169 @@
|
||||
"""Art-Net-Empfänger (PLAN.md §16.1).
|
||||
|
||||
- UDP 6454, wählbare Schnittstelle, optional Sender-Allowlist
|
||||
- ArtPoll → ArtPollReply als Media Server (Style 0x02)
|
||||
- ArtDMX-Sequenznummern auswerten, soweit vorhanden
|
||||
- mehrere Sender werden nicht still zusammengeführt: je Universe wird der
|
||||
aktive Sender vermerkt; ein Senderwechsel wird protokolliert (§16.1, §16.2)
|
||||
- Signalverlust je Universe konfigurierbar (hold/fade_to_scene/fade_to_black)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import asyncio
|
||||
import time
|
||||
from collections.abc import Callable
|
||||
from dataclasses import dataclass, field
|
||||
from enum import Enum
|
||||
|
||||
from hms_artnet.packets import build_artpoll_reply, parse_dmx, parse_poll
|
||||
|
||||
|
||||
class LossBehavior(Enum):
|
||||
"""Verhalten bei DMX-Signalverlust (§11.3)."""
|
||||
|
||||
HOLD = "hold"
|
||||
FADE_TO_BLACK = "fade_to_black"
|
||||
FADE_TO_SCENE = "fade_to_scene"
|
||||
DISABLE_SOURCE = "disable_source"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DmxUpdate:
|
||||
"""Ein DMX-Update je Universe; sequence=-1 und data=b'' = Signalverlust."""
|
||||
|
||||
universe: int
|
||||
data: bytes
|
||||
sender_ip: str
|
||||
received_ns: int
|
||||
sequence: int
|
||||
|
||||
|
||||
@dataclass
|
||||
class UniverseTelemetry:
|
||||
universe: int
|
||||
packets: int = 0
|
||||
last_received_ns: int = 0
|
||||
last_sender_ip: str = ""
|
||||
sender_changed: int = 0
|
||||
sequence_gaps: int = 0
|
||||
loss_reported: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class ArtNetReceiver:
|
||||
"""Asynchroner Art-Net-Empfänger (asyncio-Datagramm, ein Socket)."""
|
||||
|
||||
universes: set[int] = field(default_factory=set)
|
||||
bind_host: str = "0.0.0.0"
|
||||
port: int = 6454
|
||||
short_name: str = "HMS MediaEngine"
|
||||
long_name: str = "HMS MediaEngine Render Node"
|
||||
node_ip: bytes = b"\x7f\x00\x00\x01"
|
||||
mac: bytes = b"\x00\x00\x00\x00\x00\x00"
|
||||
sender_allowlist: set[str] = field(default_factory=set)
|
||||
timeout_ms: int = 2500
|
||||
loss_behavior: LossBehavior = LossBehavior.HOLD
|
||||
_transport: object | None = field(default=None, repr=False)
|
||||
_telemetry: dict[int, UniverseTelemetry] = field(default_factory=dict, repr=False)
|
||||
_last_sequence: dict[int, int] = field(default_factory=dict, repr=False)
|
||||
_handlers: list[Callable[[DmxUpdate], None]] = field(default_factory=list, repr=False)
|
||||
_watchdog_task: object | None = field(default=None, repr=False)
|
||||
|
||||
def on_dmx(self, handler: Callable[[DmxUpdate], None]) -> None:
|
||||
self._handlers.append(handler)
|
||||
|
||||
async def start(self) -> None:
|
||||
loop = asyncio.get_running_loop()
|
||||
self._transport, _ = await loop.create_datagram_endpoint(
|
||||
lambda: _Protocol(self), local_addr=(self.bind_host, self.port)
|
||||
)
|
||||
self._watchdog_task = loop.create_task(self._signal_watchdog())
|
||||
|
||||
async def stop(self) -> None:
|
||||
if self._watchdog_task:
|
||||
self._watchdog_task.cancel()
|
||||
self._watchdog_task = None
|
||||
if self._transport:
|
||||
self._transport.close()
|
||||
self._transport = None
|
||||
|
||||
def telemetry(self) -> dict[int, UniverseTelemetry]:
|
||||
return dict(self._telemetry)
|
||||
|
||||
def _handle_datagram(self, data: bytes, addr: tuple) -> None:
|
||||
sender_ip = addr[0] if addr else ""
|
||||
if self.sender_allowlist and sender_ip not in self.sender_allowlist:
|
||||
return
|
||||
if parse_poll(data) is not None:
|
||||
reply = build_artpoll_reply(
|
||||
ip=self.node_ip,
|
||||
short_name=self.short_name,
|
||||
long_name=self.long_name,
|
||||
node_report="Media Server Ready",
|
||||
mac=self.mac,
|
||||
)
|
||||
if self._transport is not None:
|
||||
self._transport.sendto(reply, addr)
|
||||
return
|
||||
dmx = parse_dmx(data)
|
||||
if dmx is None or dmx.universe not in self.universes:
|
||||
return
|
||||
tel = self._telemetry.setdefault(dmx.universe, UniverseTelemetry(dmx.universe))
|
||||
if dmx.sequence != 0:
|
||||
last = self._last_sequence.get(dmx.universe)
|
||||
if last is not None and dmx.sequence != ((last + 1) & 0xFF):
|
||||
tel.sequence_gaps += 1
|
||||
self._last_sequence[dmx.universe] = dmx.sequence
|
||||
if tel.last_sender_ip and tel.last_sender_ip != sender_ip:
|
||||
tel.sender_changed += 1
|
||||
tel.packets += 1
|
||||
tel.last_received_ns = time.monotonic_ns()
|
||||
tel.last_sender_ip = sender_ip
|
||||
tel.loss_reported = False
|
||||
update = DmxUpdate(
|
||||
universe=dmx.universe,
|
||||
data=dmx.data,
|
||||
sender_ip=sender_ip,
|
||||
received_ns=tel.last_received_ns,
|
||||
sequence=dmx.sequence,
|
||||
)
|
||||
for handler in list(self._handlers):
|
||||
handler(update)
|
||||
|
||||
async def _signal_watchdog(self) -> None:
|
||||
while True:
|
||||
await asyncio.sleep(0.5)
|
||||
now = time.monotonic_ns()
|
||||
threshold = self.timeout_ms * 1_000_000
|
||||
for uni, tel in list(self._telemetry.items()):
|
||||
if (
|
||||
tel.last_received_ns
|
||||
and not tel.loss_reported
|
||||
and now - tel.last_received_ns > threshold
|
||||
):
|
||||
tel.loss_reported = True
|
||||
for handler in list(self._handlers):
|
||||
handler(
|
||||
DmxUpdate(
|
||||
universe=uni,
|
||||
data=b"",
|
||||
sender_ip=tel.last_sender_ip,
|
||||
received_ns=now,
|
||||
sequence=-1,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
class _Protocol(asyncio.DatagramProtocol):
|
||||
def __init__(self, receiver: ArtNetReceiver) -> None:
|
||||
self._receiver = receiver
|
||||
|
||||
def datagram_received(self, data: bytes, addr: tuple) -> None:
|
||||
self._receiver._handle_datagram(data, addr)
|
||||
|
||||
def error_received(self, exc: Exception) -> None:
|
||||
# Socket-Fehler nicht schlucken (§33); an Watchdog-Protokoll escalate via log
|
||||
import logging
|
||||
|
||||
logging.getLogger("hms.artnet").error("Art-Net socket error: %s", exc)
|
||||
@@ -0,0 +1,5 @@
|
||||
"""hms_capabilities – Hardware-Erkennung und Capability-Tiers (PLAN.md §5)."""
|
||||
|
||||
from hms_capabilities.probe import CapabilityReport, CapabilityTier, detect_cpu_ram
|
||||
|
||||
__all__ = ["CapabilityTier", "CapabilityReport", "detect_cpu_ram"]
|
||||
@@ -0,0 +1,99 @@
|
||||
"""Capability-Probe (PLAN.md §5, §5.2).
|
||||
|
||||
Phase 0: plattformneutrale Basis-Erkennung (CPU/RAM/OS) und Tier-Vergabe
|
||||
nach gemessenen Fakten. GPU-/Decoder-/Display-Erkennung läuft auf dem
|
||||
Zielsystem (D3D11/GL/GLES); hier kein Fake-Ergebnis (§33: keine nicht
|
||||
getestete Dekodierung als Hardwarebeschleunigung ausgeben).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
|
||||
|
||||
class CapabilityTier(enum.StrEnum):
|
||||
DESKTOP_FULL = "DESKTOP_FULL"
|
||||
DESKTOP_LITE = "DESKTOP_LITE"
|
||||
PI_LITE = "PI_LITE"
|
||||
HEADLESS_CONTROL = "HEADLESS_CONTROL"
|
||||
|
||||
|
||||
# Mindest-VRAM für DESKTOP_FULL (§5)
|
||||
_DESKTOP_FULL_MIN_VRAM_GB = 8.0
|
||||
|
||||
|
||||
def detect_cpu_ram() -> dict[str, object]:
|
||||
"""Basis-Hardwareinformationen (plattformneutral, ohne Fake)."""
|
||||
import os
|
||||
import platform
|
||||
|
||||
info: dict[str, object] = {
|
||||
"os": platform.system(),
|
||||
"os_release": platform.release(),
|
||||
"machine": platform.machine(),
|
||||
"cpu_count": os.cpu_count() or 1,
|
||||
"ram_total_gb": _ram_gb(),
|
||||
}
|
||||
return info
|
||||
|
||||
|
||||
def _ram_gb() -> float:
|
||||
"""RAM in GB; Linux via /proc/meminfo, sonst -1 (unbekannt, nicht geraten)."""
|
||||
try:
|
||||
with open("/proc/meminfo", encoding="ascii") as fh:
|
||||
for line in fh:
|
||||
if line.startswith("MemTotal:"):
|
||||
kib = int(line.split()[1])
|
||||
return round(kib / (1024 * 1024), 2)
|
||||
except (OSError, ValueError):
|
||||
pass
|
||||
return -1.0
|
||||
|
||||
|
||||
class CapabilityReport:
|
||||
"""Ergebnis des Capability-Selbsttests (Phase 0: Skelett).
|
||||
|
||||
GPU/Decoder/Displays werden auf dem Zielsystem gemessen und hier
|
||||
ergänzt; ein Report ohne GPU-Messung kann kein DESKTOP-Tier vergeben.
|
||||
"""
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.cpu_ram = detect_cpu_ram()
|
||||
self.gpu: dict[str, object] | None = None
|
||||
self.decoders: dict[str, object] | None = None
|
||||
self.displays: dict[str, object] | None = None
|
||||
self.tier: CapabilityTier | None = None
|
||||
self.fingerprint: str = "" # an Messwerte gebunden (§5.2)
|
||||
|
||||
def conclude_tier(
|
||||
self,
|
||||
has_gpu: bool,
|
||||
vram_gb: float | None,
|
||||
decode_ok: bool,
|
||||
has_display: bool,
|
||||
) -> CapabilityTier | None:
|
||||
"""Vergibt das Tier nach gemessenen Fakten; None wenn unklar.
|
||||
|
||||
Unklar bedeutet: Gate 0 darf nicht grün melden, solange keine
|
||||
Messwerte vorliegen (§33).
|
||||
"""
|
||||
if not has_display:
|
||||
self.tier = CapabilityTier.HEADLESS_CONTROL
|
||||
return self.tier
|
||||
if not has_gpu or not decode_ok:
|
||||
return None
|
||||
if vram_gb is not None and vram_gb >= _DESKTOP_FULL_MIN_VRAM_GB:
|
||||
self.tier = CapabilityTier.DESKTOP_FULL
|
||||
else:
|
||||
self.tier = CapabilityTier.DESKTOP_LITE
|
||||
return self.tier
|
||||
|
||||
def as_dict(self) -> dict[str, object]:
|
||||
return {
|
||||
"cpu_ram": self.cpu_ram,
|
||||
"gpu": self.gpu,
|
||||
"decoders": self.decoders,
|
||||
"displays": self.displays,
|
||||
"tier": self.tier.value if self.tier else None,
|
||||
"fingerprint": self.fingerprint,
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
"""hms_domain – plattformneutrale Domänenobjekte (PLAN.md §10)."""
|
||||
|
||||
from hms_domain.ids import new_node_id, new_uuid, persistent_node_id
|
||||
|
||||
__all__ = ["new_uuid", "new_node_id", "persistent_node_id"]
|
||||
@@ -0,0 +1,53 @@
|
||||
"""Stabile IDs (PLAN.md §3.6, §10.1).
|
||||
|
||||
- UUIDs für alle Show-Objekte.
|
||||
- Persistente node_id: einmal erzeugt, dauerhaft gespeichert; unabhängig
|
||||
von IP-Adresse und Hostname (§6.3).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import os
|
||||
import uuid
|
||||
from pathlib import Path
|
||||
|
||||
|
||||
def new_uuid() -> str:
|
||||
"""Stabile UUID für Show-Objekte (Layer, Effekte, Outputs, ...)."""
|
||||
return str(uuid.uuid4())
|
||||
|
||||
|
||||
def _machine_independent_seed() -> bytes:
|
||||
"""Einstreu ohne IP/Hostname: OS-Urandom hat Priorität (§6.3)."""
|
||||
return os.urandom(16)
|
||||
|
||||
|
||||
def new_node_id() -> str:
|
||||
"""Erzeugt eine neue, netzwerkunabhängige node_id (UUIDv4)."""
|
||||
return str(uuid.UUID(bytes=_machine_independent_seed(), version=4))
|
||||
|
||||
|
||||
def persistent_node_id(identity_file: Path) -> str:
|
||||
"""Lädt die node_id aus identity_file oder erzeugt sie genau einmal.
|
||||
|
||||
IP-Wechsel ändern die node_id nicht; doppelte Vergabe über die Datei
|
||||
wird durch exklusives Erzeugen (O_EXCL) verhindert.
|
||||
"""
|
||||
identity_file = Path(identity_file)
|
||||
if identity_file.exists():
|
||||
existing = identity_file.read_text(encoding="utf-8").strip()
|
||||
if existing:
|
||||
uuid.UUID(existing) # Validierung: muss UUID sein
|
||||
return existing
|
||||
identity_file.parent.mkdir(parents=True, exist_ok=True)
|
||||
candidate = new_node_id()
|
||||
try:
|
||||
fd = os.open(identity_file, os.O_CREAT | os.O_EXCL | os.O_WRONLY, 0o600)
|
||||
with os.fdopen(fd, "w", encoding="utf-8") as fh:
|
||||
fh.write(candidate)
|
||||
return candidate
|
||||
except FileExistsError:
|
||||
existing = identity_file.read_text(encoding="utf-8").strip()
|
||||
if not existing:
|
||||
raise
|
||||
return existing
|
||||
@@ -0,0 +1,23 @@
|
||||
"""hms_parameter – zentrale Parameter- und Control-Engine (PLAN.md §11)."""
|
||||
|
||||
from hms_parameter.engine import (
|
||||
ControlSource,
|
||||
MergeMode,
|
||||
ParameterEngine,
|
||||
ParameterFrame,
|
||||
)
|
||||
from hms_parameter.paths import (
|
||||
layer_opacity_path,
|
||||
master_intensity_path,
|
||||
validate_parameter_path,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"ControlSource",
|
||||
"MergeMode",
|
||||
"ParameterEngine",
|
||||
"ParameterFrame",
|
||||
"validate_parameter_path",
|
||||
"layer_opacity_path",
|
||||
"master_intensity_path",
|
||||
]
|
||||
@@ -0,0 +1,156 @@
|
||||
"""Parameter-Engine: Prioritäten, Übernahme, Frame-Snapshot (PLAN.md §11).
|
||||
|
||||
Alle Steuerquellen (Browser, Art-Net, später Timeline/Audio/KI) laufen über
|
||||
diese Engine; direkte Renderer-Zugriffe sind verboten (§11, §33).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import enum
|
||||
import time
|
||||
from dataclasses import dataclass, field
|
||||
|
||||
from hms_parameter.paths import validate_parameter_path
|
||||
|
||||
|
||||
class ControlSource(enum.IntEnum):
|
||||
"""Steuerquellen in Prioritätsordnung (§11.2)."""
|
||||
|
||||
SAFETY = 1 # Not-Aus/Blackout, überstimmt alles
|
||||
OPERATOR = 2 # expliziter manueller Override
|
||||
CONSOLE = 3 # freigegebenes Lichtpult (Art-Net)
|
||||
WEB = 4 # Browser-Livebedienung
|
||||
TIMELINE = 5 # reserviert
|
||||
AUDIO = 6 # reserviert (Modulatoren)
|
||||
AI = 7 # reserviert, niedrigste Priorität
|
||||
|
||||
|
||||
class MergeMode(enum.Enum):
|
||||
"""Übernahmeverfahren (§11.3)."""
|
||||
|
||||
LTP = "ltp" # letzte Änderung gewinnt (Standard)
|
||||
HTP = "htp" # höchster Wert gewinnt (optional für Intensität)
|
||||
|
||||
|
||||
@dataclass
|
||||
class _Binding:
|
||||
value: float
|
||||
last_change_ns: int
|
||||
|
||||
|
||||
class ParameterFrame:
|
||||
"""Unveränderlicher Snapshot aller Parameter für genau einen Frame (§11.4)."""
|
||||
|
||||
__slots__ = ("_values", "revision", "created_ns")
|
||||
|
||||
def __init__(self, values: dict[str, float], revision: int) -> None:
|
||||
object.__setattr__(self, "_values", dict(values))
|
||||
object.__setattr__(self, "revision", revision)
|
||||
object.__setattr__(self, "created_ns", time.monotonic_ns())
|
||||
|
||||
def get(self, path: str, default: float = 0.0) -> float:
|
||||
return self._values.get(path, default)
|
||||
|
||||
def as_dict(self) -> dict[str, float]:
|
||||
return dict(self._values)
|
||||
|
||||
def __contains__(self, path: str) -> bool:
|
||||
return path in self._values
|
||||
|
||||
|
||||
class RevisionConflict(Exception):
|
||||
"""Erwartete Revision stimmt nicht (optimistische Sperre, §23.2)."""
|
||||
|
||||
def __init__(self, current: int, expected: int) -> None:
|
||||
self.current = current
|
||||
self.expected = expected
|
||||
super().__init__(f"revision conflict: current={current}, expected={expected}")
|
||||
|
||||
|
||||
@dataclass
|
||||
class ParameterEngine:
|
||||
"""Autoritative Parameter-Instanz des Control Core.
|
||||
|
||||
- set_value: Override einer Quelle mit Prioritätsprüfung
|
||||
- release: Rückgabe an nächstniedrigere Quelle (§11.3)
|
||||
- snapshot: atomarer Frame-Snapshot (§11.4)
|
||||
"""
|
||||
|
||||
revision: int = 0
|
||||
default: float = 0.0
|
||||
merge_mode: MergeMode = MergeMode.LTP
|
||||
_bindings: dict[str, dict[ControlSource, _Binding]] = field(
|
||||
default_factory=dict, repr=False
|
||||
)
|
||||
_defaults: dict[str, float] = field(default_factory=dict, repr=False)
|
||||
|
||||
def set_value(
|
||||
self,
|
||||
path: str,
|
||||
value: float,
|
||||
source: ControlSource,
|
||||
expected_revision: int | None = None,
|
||||
) -> int:
|
||||
"""Setzt einen Override; gibt die neue Revision zurück."""
|
||||
if not validate_parameter_path(path):
|
||||
raise ValueError(f"invalid parameter path: {path!r}")
|
||||
value = float(value)
|
||||
if value != value or value in (float("inf"), float("-inf")):
|
||||
raise ValueError(f"value must be finite, got {value}")
|
||||
if expected_revision is not None and expected_revision != self.revision:
|
||||
raise RevisionConflict(self.revision, expected_revision)
|
||||
|
||||
per_source = self._bindings.setdefault(path, {})
|
||||
# Priorität: eine niedrigere Quelle kann eine höhere Quelle nicht
|
||||
# verdrängen, aber ihre eigene Bindung jederzeit aktualisieren.
|
||||
existing = per_source.get(source)
|
||||
now = time.monotonic_ns()
|
||||
if existing is None:
|
||||
per_source[source] = _Binding(value, now)
|
||||
self.revision += 1
|
||||
elif self.merge_mode is MergeMode.LTP:
|
||||
# LTP: jede Übernahme aktualisiert Bindung und Revision (§11.3)
|
||||
per_source[source] = _Binding(value, now)
|
||||
self.revision += 1
|
||||
elif value > existing.value:
|
||||
# HTP: nur ein höherer Wert übernimmt; Maximum bleibt (§11.3)
|
||||
per_source[source] = _Binding(value, now)
|
||||
self.revision += 1
|
||||
return self.revision
|
||||
|
||||
def effective_value(self, path: str) -> float:
|
||||
"""Wirksamer Wert: höchste Priorität gewinnt; sonst Default (§11.1)."""
|
||||
per_source = self._bindings.get(path)
|
||||
if not per_source:
|
||||
return self._defaults.get(path, self.default)
|
||||
source = min(per_source) # kleinster IntEnum-Wert = höchste Priorität
|
||||
return per_source[source].value
|
||||
|
||||
def current_source(self, path: str) -> ControlSource | None:
|
||||
per_source = self._bindings.get(path)
|
||||
if not per_source:
|
||||
return None
|
||||
return min(per_source)
|
||||
|
||||
def release(self, path: str, source: ControlSource) -> int:
|
||||
"""Gibt den Override zurück; nächstniedrigere Quelle übernimmt (§11.3)."""
|
||||
per_source = self._bindings.get(path)
|
||||
if per_source and source in per_source:
|
||||
del per_source[source]
|
||||
if not per_source:
|
||||
self._bindings.pop(path, None)
|
||||
self.revision += 1
|
||||
return self.revision
|
||||
|
||||
def snapshot(self) -> ParameterFrame:
|
||||
"""Atomarer Snapshot aller wirksamen Werte für einen Frame (§11.4)."""
|
||||
values = {p: self._defaults[p] for p in self._defaults}
|
||||
for path, per_source in self._bindings.items():
|
||||
if per_source:
|
||||
values[path] = per_source[min(per_source)].value
|
||||
return ParameterFrame(values, self.revision)
|
||||
|
||||
def set_default(self, path: str, value: float) -> None:
|
||||
if not validate_parameter_path(path):
|
||||
raise ValueError(f"invalid parameter path: {path!r}")
|
||||
self._defaults[path] = float(value)
|
||||
@@ -0,0 +1,49 @@
|
||||
"""Stabile Parameterpfade (PLAN.md §10.2).
|
||||
|
||||
Pfade werden niemals aus sichtbaren Namen gebildet; alle Teile sind UUIDs
|
||||
oder feste Schlüsselwörter.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import uuid
|
||||
|
||||
_ALLOWED_ROOTS = {"composition", "output", "cluster", "master"}
|
||||
|
||||
|
||||
def _is_uuid(value: str) -> bool:
|
||||
try:
|
||||
uuid.UUID(value)
|
||||
return True
|
||||
except (ValueError, AttributeError):
|
||||
return False
|
||||
|
||||
|
||||
def validate_parameter_path(path: str) -> bool:
|
||||
"""True, wenn der Pfad dem Muster §10.2 entspricht."""
|
||||
if not path or path.startswith("/") or "\\" in path or ".." in path:
|
||||
return False
|
||||
parts = path.split("/")
|
||||
root = parts[0]
|
||||
if root not in _ALLOWED_ROOTS:
|
||||
return False
|
||||
if root == "master":
|
||||
return len(parts) == 2 and parts[1] != ""
|
||||
if root in {"composition", "output"}:
|
||||
if len(parts) < 3:
|
||||
return False
|
||||
if not _is_uuid(parts[1]):
|
||||
return False
|
||||
return all(p != "" for p in parts[2:])
|
||||
# cluster: cluster/group/{uuid}/... oder cluster/node/{uuid}/...
|
||||
if len(parts) >= 3 and parts[1] in {"group", "node"} and _is_uuid(parts[2]):
|
||||
return all(p != "" for p in parts[3:])
|
||||
return False
|
||||
|
||||
|
||||
def layer_opacity_path(composition_id: str, layer_id: str) -> str:
|
||||
return f"composition/{composition_id}/layer/{layer_id}/opacity"
|
||||
|
||||
|
||||
def master_intensity_path() -> str:
|
||||
return "master/intensity"
|
||||
@@ -0,0 +1,15 @@
|
||||
"""hms_plugin_sdk – Plugin-API, Manifest, Validierung (PLAN.md §14)."""
|
||||
|
||||
from hms_plugin_sdk.manifest import (
|
||||
PluginKind,
|
||||
load_manifest,
|
||||
validate_manifest,
|
||||
validate_plugin_zip,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"PluginKind",
|
||||
"load_manifest",
|
||||
"validate_manifest",
|
||||
"validate_plugin_zip",
|
||||
]
|
||||
@@ -0,0 +1,227 @@
|
||||
"""Plugin-Manifest und Validierung (PLAN.md §14.2–14.6, §27.2).
|
||||
|
||||
Sicherheitsgrenzen:
|
||||
- Pfadsicherheit: keine absoluten Pfade, kein '..' in Manifest und ZIP
|
||||
- ZIP-Bomb-Limits, Dateigrößenlimits, erlaubte Dateitypen
|
||||
- eindeutige Plugin-ID (reverse-dns), SemVer, api_version
|
||||
- Shader-Dateien müssen je deklariertem Backend existieren
|
||||
- max. 8 generische DMX-Slots je Effektinstanz (§14.7)
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import zipfile
|
||||
from enum import StrEnum
|
||||
from pathlib import Path, PurePosixPath
|
||||
from typing import Any
|
||||
|
||||
MANIFEST_SCHEMA_VERSION = 1
|
||||
MAX_PLUGIN_FILES = 512
|
||||
MAX_TOTAL_UNPACKED = 32 * 1024 * 1024
|
||||
MAX_FILE_SIZE = 8 * 1024 * 1024
|
||||
_ALLOWED_SUFFIXES = {
|
||||
".json",
|
||||
".hlsl",
|
||||
".frag",
|
||||
".vert",
|
||||
".glsl",
|
||||
".png",
|
||||
".md",
|
||||
".txt",
|
||||
".toml",
|
||||
".csv",
|
||||
}
|
||||
_ALLOWED_BACKENDS = {"d3d11", "gl", "gles"}
|
||||
|
||||
|
||||
class PluginKind(StrEnum):
|
||||
SOURCE = "source"
|
||||
GENERATOR = "generator"
|
||||
FILTER = "filter"
|
||||
TRANSITION = "transition"
|
||||
MIXER = "mixer"
|
||||
OUTPUT = "output"
|
||||
CONTROL = "control"
|
||||
AUTOMATION = "automation"
|
||||
|
||||
|
||||
def _safe_relative(raw: str) -> PurePosixPath | None:
|
||||
"""Prüft Pfadsicherheit; None wenn unsicher (absolut oder Traversal)."""
|
||||
if not raw:
|
||||
return None
|
||||
p = PurePosixPath(raw)
|
||||
if p.is_absolute() or ".." in p.parts:
|
||||
return None
|
||||
return p
|
||||
|
||||
|
||||
def _validate_parameters(params: list[dict[str, Any]]) -> list[str]:
|
||||
errors: list[str] = []
|
||||
seen: set[str] = set()
|
||||
total_dmx_slots = 0
|
||||
for param in params:
|
||||
pid = param.get("id")
|
||||
if not pid or not isinstance(pid, str):
|
||||
errors.append("parameter without id")
|
||||
continue
|
||||
if pid in seen:
|
||||
errors.append(f"duplicate parameter id: {pid}")
|
||||
seen.add(pid)
|
||||
ptype = param.get("type")
|
||||
if ptype not in {"float", "int", "enum", "bool", "color"}:
|
||||
errors.append(f"parameter {pid}: invalid type {ptype!r}")
|
||||
if ptype == "float":
|
||||
for key in ("minimum", "maximum", "default"):
|
||||
if key not in param:
|
||||
errors.append(f"parameter {pid}: missing {key}")
|
||||
slots = param.get("dmx_slots", [])
|
||||
if not isinstance(slots, list) or any(not isinstance(s, int) for s in slots):
|
||||
errors.append(f"parameter {pid}: dmx_slots must be int list")
|
||||
slots = []
|
||||
total_dmx_slots += len(slots)
|
||||
if total_dmx_slots > 8:
|
||||
errors.append(f"dmx slot footprint {total_dmx_slots} exceeds 8 (§14.7)")
|
||||
return errors
|
||||
|
||||
|
||||
def _valid_plugin_id(pid: str) -> bool:
|
||||
if ".." in pid or len(pid) < 5:
|
||||
return False
|
||||
parts = pid.split(".")
|
||||
if len(parts) < 2:
|
||||
return False
|
||||
allowed = set("abcdefghijklmnopqrstuvwxyz0123456789._-")
|
||||
return all(c in allowed for c in pid)
|
||||
|
||||
|
||||
def _valid_semver(version: str) -> bool:
|
||||
parts = version.split(".")
|
||||
if len(parts) != 3:
|
||||
return False
|
||||
try:
|
||||
for p in parts:
|
||||
int(p)
|
||||
except ValueError:
|
||||
return False
|
||||
return True
|
||||
|
||||
|
||||
def validate_manifest(
|
||||
manifest: dict[str, Any], plugin_root: Path | None = None
|
||||
) -> list[str]:
|
||||
"""Validiert ein geparstes Manifest; leere Fehlerliste = gültig.
|
||||
|
||||
plugin_root: wenn gesetzt, werden deklarierte Shader auf Existenz geprüft.
|
||||
"""
|
||||
errors: list[str] = []
|
||||
|
||||
if manifest.get("schema_version") != MANIFEST_SCHEMA_VERSION:
|
||||
errors.append(f"schema_version must be {MANIFEST_SCHEMA_VERSION}")
|
||||
|
||||
pid = manifest.get("id", "")
|
||||
if not isinstance(pid, str) or not _valid_plugin_id(pid):
|
||||
errors.append(f"invalid plugin id: {pid!r} (expected reverse-dns)")
|
||||
|
||||
for key in ("name", "version", "vendor"):
|
||||
value = manifest.get(key)
|
||||
if not isinstance(value, str) or not value:
|
||||
errors.append(f"missing or empty {key}")
|
||||
|
||||
if not _valid_semver(manifest.get("version", "")):
|
||||
errors.append("version must be semantic (X.Y.Z)")
|
||||
|
||||
if manifest.get("api_version") != MANIFEST_SCHEMA_VERSION:
|
||||
errors.append(f"api_version must be {MANIFEST_SCHEMA_VERSION}")
|
||||
|
||||
if manifest.get("kind") not in {k.value for k in PluginKind}:
|
||||
errors.append(f"invalid kind: {manifest.get('kind')!r}")
|
||||
|
||||
entrypoints = manifest.get("entrypoints", {})
|
||||
if not isinstance(entrypoints, dict) or not entrypoints:
|
||||
errors.append("entrypoints required")
|
||||
else:
|
||||
supported = set(manifest.get("capabilities", {}).get("supported_backends", []))
|
||||
unknown = supported - _ALLOWED_BACKENDS
|
||||
if unknown:
|
||||
errors.append(f"unsupported backends: {sorted(unknown)}")
|
||||
for backend, entry in entrypoints.items():
|
||||
if backend not in _ALLOWED_BACKENDS:
|
||||
errors.append(f"entrypoint backend {backend!r} not allowed")
|
||||
continue
|
||||
if backend in supported:
|
||||
passes = entry.get("passes", [])
|
||||
if not passes:
|
||||
errors.append(f"entrypoint {backend}: no passes")
|
||||
for pas in passes:
|
||||
shader_key = "pixel_shader" if "pixel_shader" in pas else "fragment"
|
||||
shader_rel = pas.get(shader_key)
|
||||
if not shader_rel:
|
||||
errors.append(f"entrypoint {backend}: pass without shader")
|
||||
continue
|
||||
sp = _safe_relative(shader_rel)
|
||||
if sp is None:
|
||||
errors.append(f"unsafe shader path: {shader_rel!r}")
|
||||
continue
|
||||
if plugin_root is not None and not (plugin_root / sp).is_file():
|
||||
errors.append(f"missing shader file: {shader_rel}")
|
||||
|
||||
params = manifest.get("parameters", [])
|
||||
if not isinstance(params, list):
|
||||
errors.append("parameters must be a list")
|
||||
else:
|
||||
errors.extend(_validate_parameters(params))
|
||||
|
||||
if manifest.get("failure_mode") not in {"bypass", "hold", "black"}:
|
||||
errors.append("failure_mode must be bypass|hold|black")
|
||||
|
||||
return errors
|
||||
|
||||
|
||||
def validate_plugin_zip(zip_path: Path) -> list[str]:
|
||||
"""Prüft ein Plugin-ZIP: Pfadsicherheit, Limits, Typen, Manifest (§27.2)."""
|
||||
errors: list[str] = []
|
||||
try:
|
||||
with zipfile.ZipFile(zip_path) as zf:
|
||||
names = zf.namelist()
|
||||
if len(names) > MAX_PLUGIN_FILES:
|
||||
errors.append(f"too many files: {len(names)} > {MAX_PLUGIN_FILES}")
|
||||
total = 0
|
||||
for info in zf.infolist():
|
||||
if info.is_dir():
|
||||
continue
|
||||
total += info.file_size
|
||||
if info.file_size > MAX_FILE_SIZE:
|
||||
errors.append(f"file too large: {info.filename}")
|
||||
if _safe_relative(info.filename) is None:
|
||||
errors.append(f"unsafe path in zip: {info.filename!r}")
|
||||
if Path(info.filename).suffix.lower() not in _ALLOWED_SUFFIXES:
|
||||
errors.append(f"disallowed file type: {info.filename}")
|
||||
if total > MAX_TOTAL_UNPACKED:
|
||||
errors.append(f"zip too large unpacked: {total} > {MAX_TOTAL_UNPACKED}")
|
||||
manifest_name = next(
|
||||
(n for n in names if n.endswith("plugin.json") and n.count("/") == 1),
|
||||
None,
|
||||
)
|
||||
if manifest_name is None:
|
||||
errors.append("plugin.json not found at package root")
|
||||
else:
|
||||
manifest = json.loads(zf.read(manifest_name))
|
||||
errors.extend(validate_manifest(manifest))
|
||||
except zipfile.BadZipFile:
|
||||
errors.append("not a valid zip file")
|
||||
except json.JSONDecodeError as exc:
|
||||
errors.append(f"plugin.json invalid JSON: {exc}")
|
||||
return errors
|
||||
|
||||
|
||||
def load_manifest(plugin_dir: Path) -> tuple[dict[str, Any], list[str]]:
|
||||
"""Lädt und validiert plugin.json aus einem Plugin-Verzeichnis."""
|
||||
manifest_path = plugin_dir / "plugin.json"
|
||||
if not manifest_path.is_file():
|
||||
return {}, ["plugin.json missing"]
|
||||
try:
|
||||
manifest = json.loads(manifest_path.read_text(encoding="utf-8"))
|
||||
except json.JSONDecodeError as exc:
|
||||
return {}, [f"plugin.json invalid JSON: {exc}"]
|
||||
return manifest, validate_manifest(manifest, plugin_root=plugin_dir)
|
||||
@@ -0,0 +1,18 @@
|
||||
"""hms_protocol – versioniertes IPC (PLAN.md §6.2, ADR-0003).
|
||||
|
||||
Lokales TCP auf 127.0.0.1, length-prefixed MessagePack, Protokollversion 1.
|
||||
"""
|
||||
|
||||
from hms_protocol.envelope import Envelope, MessageType
|
||||
from hms_protocol.framing import decode_frame, encode_frame, read_frame, write_frame
|
||||
from hms_protocol.idempotency import IdempotencyRegistry
|
||||
|
||||
__all__ = [
|
||||
"Envelope",
|
||||
"MessageType",
|
||||
"encode_frame",
|
||||
"decode_frame",
|
||||
"read_frame",
|
||||
"write_frame",
|
||||
"IdempotencyRegistry",
|
||||
]
|
||||
@@ -0,0 +1,37 @@
|
||||
"""IPC-Nachrichten-Umschlag (PLAN.md §6.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import time
|
||||
import uuid
|
||||
from enum import StrEnum
|
||||
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
PROTOCOL_VERSION = 1
|
||||
|
||||
|
||||
class MessageType(StrEnum):
|
||||
COMMAND = "command"
|
||||
EVENT = "event"
|
||||
SNAPSHOT = "snapshot"
|
||||
ACK = "ack"
|
||||
ERROR = "error"
|
||||
TELEMETRY = "telemetry"
|
||||
|
||||
|
||||
class Envelope(BaseModel):
|
||||
"""Jede IPC-Nachricht besitzt mindestens diese Felder (§6.2)."""
|
||||
|
||||
protocol_version: int = PROTOCOL_VERSION
|
||||
message_id: str = Field(default_factory=lambda: str(uuid.uuid4()))
|
||||
type: MessageType
|
||||
revision: int = 0
|
||||
monotonic_timestamp_ns: int = Field(default_factory=lambda: time.monotonic_ns())
|
||||
payload: dict = Field(default_factory=dict)
|
||||
|
||||
def model_post_init(self, _ctx: object) -> None:
|
||||
if self.protocol_version != PROTOCOL_VERSION:
|
||||
raise ValueError(
|
||||
f"protocol_version {self.protocol_version} != {PROTOCOL_VERSION}"
|
||||
)
|
||||
@@ -0,0 +1,61 @@
|
||||
"""Length-prefixed MessagePack-Framing (ADR-0003).
|
||||
|
||||
4-Byte-Big-Endian-Länge, danach MessagePack-Payload. Maximale Payloadgröße
|
||||
schützt vor unkontrollierten Queues/Resourcenerschöpfung (§6.2, §33).
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import struct
|
||||
|
||||
import msgpack
|
||||
|
||||
MAX_PAYLOAD_SIZE = 16 * 1024 * 1024 # 16 MiB Obergrenze je Nachricht
|
||||
_LENGTH = struct.Struct(">I")
|
||||
|
||||
|
||||
def encode_frame(payload: dict) -> bytes:
|
||||
"""Serialisiert ein dict zu length-prefixed MessagePack."""
|
||||
body = msgpack.packb(payload, use_bin_type=True)
|
||||
if len(body) > MAX_PAYLOAD_SIZE:
|
||||
raise ValueError(f"payload too large: {len(body)} > {MAX_PAYLOAD_SIZE}")
|
||||
return _LENGTH.pack(len(body)) + body
|
||||
|
||||
|
||||
def decode_frame(frame: bytes) -> dict:
|
||||
"""Dekodiert einen vollständigen Frame (Länge + Body)."""
|
||||
if len(frame) < _LENGTH.size:
|
||||
raise ValueError("frame too short")
|
||||
(length,) = _LENGTH.unpack_from(frame, 0)
|
||||
if length > MAX_PAYLOAD_SIZE:
|
||||
raise ValueError(f"declared length {length} exceeds limit")
|
||||
body = frame[_LENGTH.size : _LENGTH.size + length]
|
||||
if len(body) != length:
|
||||
raise ValueError(f"truncated frame: expected {length}, got {len(body)}")
|
||||
return msgpack.unpackb(body, raw=False)
|
||||
|
||||
|
||||
def read_frame(sock: socket.socket) -> dict:
|
||||
"""Liest einen Frame von einem verbundenen Socket."""
|
||||
header = _recv_exact(sock, _LENGTH.size)
|
||||
(length,) = _LENGTH.unpack(header)
|
||||
if length > MAX_PAYLOAD_SIZE:
|
||||
raise ValueError(f"declared length {length} exceeds limit")
|
||||
body = _recv_exact(sock, length)
|
||||
return msgpack.unpackb(body, raw=False)
|
||||
|
||||
|
||||
def write_frame(sock: socket.socket, payload: dict) -> None:
|
||||
"""Schreibt einen Frame auf einen verbundenen Socket."""
|
||||
sock.sendall(encode_frame(payload))
|
||||
|
||||
|
||||
def _recv_exact(sock: socket.socket, count: int) -> bytes:
|
||||
buf = bytearray()
|
||||
while len(buf) < count:
|
||||
chunk = sock.recv(count - len(buf))
|
||||
if not chunk:
|
||||
raise ConnectionError("socket closed mid-frame")
|
||||
buf.extend(chunk)
|
||||
return bytes(buf)
|
||||
@@ -0,0 +1,37 @@
|
||||
"""Idempotency-Registry für wiederholbare Commands (§6.2, §23.2)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
from typing import Any
|
||||
|
||||
|
||||
class IdempotencyRegistry:
|
||||
"""Merkt sich command_id → Ergebnis; Wiederholungen liefern dasselbe Ack."""
|
||||
|
||||
def __init__(self, capacity: int = 4096) -> None:
|
||||
if capacity <= 0:
|
||||
raise ValueError("capacity must be positive")
|
||||
self._capacity = capacity
|
||||
self._entries: OrderedDict[str, Any] = OrderedDict()
|
||||
|
||||
def register(self, command_id: str) -> bool:
|
||||
"""False, wenn die command_id bereits bekannt ist (Duplikat)."""
|
||||
if command_id in self._entries:
|
||||
self._entries.move_to_end(command_id)
|
||||
return False
|
||||
self._entries[command_id] = None # Ergebnis folgt mit complete()
|
||||
if len(self._entries) > self._capacity:
|
||||
self._entries.popitem(last=False)
|
||||
return True
|
||||
|
||||
def complete(self, command_id: str, result: Any) -> None:
|
||||
if command_id in self._entries:
|
||||
self._entries[command_id] = result
|
||||
self._entries.move_to_end(command_id)
|
||||
|
||||
def result(self, command_id: str) -> Any | None:
|
||||
return self._entries.get(command_id)
|
||||
|
||||
def __len__(self) -> int:
|
||||
return len(self._entries)
|
||||
@@ -0,0 +1,13 @@
|
||||
# Example Passthrough (SDK-Referenz)
|
||||
|
||||
Erstes Beispielplugin gemäß PLAN.md §14 und §36 Nr. 6: HLSL-Passthrough mit einem live änderbaren Parameter `mix` sowie semantisch gleiche GLSL- und GLES-Testvarianten.
|
||||
|
||||
- `shaders/d3d11/passthrough.hlsl` – Windows-Primärpfad (D3D11, PS_5_0)
|
||||
- `shaders/gl/passthrough.frag` – Linux x64 (GLSL 330)
|
||||
- `shaders/gles/passthrough.frag` – Raspberry Pi / GLES (100)
|
||||
|
||||
Alle Varianten verwenden dieselben semantischen Standard-Inputs (§14.4).
|
||||
Das Plugin validiert fehlerfrei gegen `hms_plugin_sdk` (Validator-Test in `tests/unit/test_plugin_manifest.py`).
|
||||
|
||||
Shader-Kompilierung und Bildgleichheit werden auf Zielsystemen geprüft
|
||||
(Gate-0-Hardwaremessung, §29.3); dieser Container besitzt keine GPU.
|
||||
@@ -0,0 +1,57 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"id": "com.hms.fx.example_passthrough",
|
||||
"name": "Example Passthrough",
|
||||
"version": "1.0.0",
|
||||
"api_version": 1,
|
||||
"kind": "filter",
|
||||
"vendor": "HMS",
|
||||
"entrypoints": {
|
||||
"d3d11": {
|
||||
"type": "hlsl_singlepass",
|
||||
"passes": [
|
||||
{"pixel_shader": "shaders/d3d11/passthrough.hlsl"}
|
||||
]
|
||||
},
|
||||
"gl": {
|
||||
"type": "glsl_singlepass",
|
||||
"passes": [
|
||||
{"fragment": "shaders/gl/passthrough.frag"}
|
||||
]
|
||||
},
|
||||
"gles": {
|
||||
"type": "glsl_es_singlepass",
|
||||
"passes": [
|
||||
{"fragment": "shaders/gles/passthrough.frag"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"minimum_tier": "PI_LITE",
|
||||
"requires_input_texture": true,
|
||||
"supported_backends": ["d3d11", "gl", "gles"]
|
||||
},
|
||||
"adaptive_quality": {
|
||||
"default": "auto",
|
||||
"variants": [
|
||||
{"id": "low", "internal_scale": 1.0, "samples": 1},
|
||||
{"id": "medium", "internal_scale": 1.0, "samples": 1},
|
||||
{"id": "high", "internal_scale": 1.0, "samples": 1}
|
||||
],
|
||||
"transition_ms": 180,
|
||||
"semantic_parameters_unchanged": ["mix"]
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"id": "mix",
|
||||
"label": "Mix",
|
||||
"type": "float",
|
||||
"minimum": 0.0,
|
||||
"maximum": 1.0,
|
||||
"default": 0.0,
|
||||
"dmx_slots": [1],
|
||||
"curve": "linear"
|
||||
}
|
||||
],
|
||||
"failure_mode": "bypass"
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
// HMS MediaEngine – Beispielplugin: Passthrough mit Live-Parameter "mix"
|
||||
// PLAN.md §14.4 (Standard-Shaderinputs), §36 Nr. 6
|
||||
// D3D11-Pixelshader (PS_5_0); Bindung übernimmt der Backend-Adapter (ADR-0004 offen).
|
||||
// Projekte referenzieren niemals konkrete Backend-Variablennamen (§12.6).
|
||||
|
||||
Texture2D u_input_texture : register(t0);
|
||||
SamplerState u_sampler : register(s0);
|
||||
|
||||
cbuffer hms_params : register(b0)
|
||||
{
|
||||
float4 u_resolution; // xy = Auflösung in Pixeln
|
||||
float u_time_seconds;
|
||||
float u_delta_seconds;
|
||||
float u_frame_index;
|
||||
float u_layer_opacity;
|
||||
float u_audio_rms;
|
||||
float u_audio_peak;
|
||||
float u_audio_bass;
|
||||
float u_audio_mid;
|
||||
float u_audio_treble;
|
||||
float u_audio_beat;
|
||||
float param_mix; // Plugin-Parameter, live änderbar (0..1)
|
||||
float _pad0; // 16-Byte-Alignment des cbuffer
|
||||
};
|
||||
|
||||
float4 mainPS(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target
|
||||
{
|
||||
float4 src = u_input_texture.Sample(u_sampler, uv);
|
||||
|
||||
// Live-Parameter: mix blendet zwischen Original und zeitmodulierter Helligkeit
|
||||
float pulse = 0.5 + 0.5 * sin(u_time_seconds * 2.0);
|
||||
float m = saturate(param_mix);
|
||||
float3 rgb = src.rgb * lerp(1.0, pulse, m);
|
||||
float a = src.a * u_layer_opacity;
|
||||
|
||||
return float4(rgb * u_layer_opacity, a);
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
#version 330 core
|
||||
// HMS MediaEngine – Beispielplugin: Passthrough mit Live-Parameter "mix"
|
||||
// GLSL-Testvariante, semantisch identisch zur HLSL-Implementierung (§36 Nr. 6).
|
||||
// Standard-Inputs gemäß PLAN.md §14.4; Bindung über den GL-Backend-Adapter.
|
||||
|
||||
uniform sampler2D u_input_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time_seconds;
|
||||
uniform float u_delta_seconds;
|
||||
uniform float u_frame_index;
|
||||
uniform float u_layer_opacity;
|
||||
uniform float u_audio_rms;
|
||||
uniform float u_audio_peak;
|
||||
uniform float u_audio_bass;
|
||||
uniform float u_audio_mid;
|
||||
uniform float u_audio_treble;
|
||||
uniform float u_audio_beat;
|
||||
uniform float param_mix;
|
||||
|
||||
in vec2 v_uv;
|
||||
out vec4 fragColor;
|
||||
|
||||
void main()
|
||||
{
|
||||
vec4 src = texture(u_input_texture, v_uv);
|
||||
|
||||
float pulse = 0.5 + 0.5 * sin(u_time_seconds * 2.0);
|
||||
float m = clamp(param_mix, 0.0, 1.0);
|
||||
vec3 rgb = src.rgb * mix(1.0, pulse, m);
|
||||
float a = src.a * u_layer_opacity;
|
||||
|
||||
fragColor = vec4(rgb * u_layer_opacity, a);
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
#version 100
|
||||
// HMS MediaEngine – Beispielplugin: Passthrough (OpenGL ES, Raspberry Pi Pfad)
|
||||
// Semantisch identisch zu HLSL/GLSL-Varianten (§36 Nr. 6, §12.6).
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_input_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time_seconds;
|
||||
uniform float u_delta_seconds;
|
||||
uniform float u_frame_index;
|
||||
uniform float u_layer_opacity;
|
||||
uniform float u_audio_rms;
|
||||
uniform float u_audio_peak;
|
||||
uniform float u_audio_bass;
|
||||
uniform float u_audio_mid;
|
||||
uniform float u_audio_treble;
|
||||
uniform float u_audio_beat;
|
||||
uniform float param_mix;
|
||||
|
||||
varying vec2 v_uv;
|
||||
|
||||
void main()
|
||||
{
|
||||
vec4 src = texture2D(u_input_texture, v_uv);
|
||||
|
||||
float pulse = 0.5 + 0.5 * sin(u_time_seconds * 2.0);
|
||||
float m = clamp(param_mix, 0.0, 1.0);
|
||||
vec3 rgb = src.rgb * mix(1.0, pulse, m);
|
||||
float a = src.a * u_layer_opacity;
|
||||
|
||||
gl_FragColor = vec4(rgb * u_layer_opacity, a);
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
# Gaussian Blur (SDK-Beispiel, PLAN.md §14.3 / §15.2 / §36 Nr. 10)
|
||||
|
||||
Separierbarer Gauß-Blur mit zwei Pässen (horizontal + vertikal) je Backend.
|
||||
|
||||
Adaptive Quality: drei deklarierte Varianten (low/medium/high), die sich nur
|
||||
in `internal_scale` und `samples` unterscheiden; `radius` bleibt semantisch
|
||||
unverändert (§5.2). Radius 0 bypassed kostenfrei (§15.3).
|
||||
|
||||
| Variante | internal_scale | samples |
|
||||
| --- | --- | --- |
|
||||
| low | 0.25 | 5 |
|
||||
| medium | 0.5 | 9 |
|
||||
| high | 1.0 | 17 |
|
||||
|
||||
Alle Varianten müssen vor Aktivierung kompiliert werden (§5.2, §33);
|
||||
Bild- und Performance-Abnahme erfolgt auf Zielsystemen (§15.4).
|
||||
@@ -0,0 +1,68 @@
|
||||
{
|
||||
"schema_version": 1,
|
||||
"id": "com.hms.fx.gaussian_blur",
|
||||
"name": "Gaussian Blur",
|
||||
"version": "1.0.0",
|
||||
"api_version": 1,
|
||||
"kind": "filter",
|
||||
"vendor": "HMS",
|
||||
"entrypoints": {
|
||||
"d3d11": {
|
||||
"type": "hlsl_multipass",
|
||||
"passes": [
|
||||
{"pixel_shader": "shaders/d3d11/horizontal.hlsl"},
|
||||
{"pixel_shader": "shaders/d3d11/vertical.hlsl"}
|
||||
]
|
||||
},
|
||||
"gl": {
|
||||
"type": "glsl_multipass",
|
||||
"passes": [
|
||||
{"fragment": "shaders/gl/horizontal.frag"},
|
||||
{"fragment": "shaders/gl/vertical.frag"}
|
||||
]
|
||||
},
|
||||
"gles": {
|
||||
"type": "glsl_es_multipass",
|
||||
"passes": [
|
||||
{"fragment": "shaders/gles/horizontal.frag"},
|
||||
{"fragment": "shaders/gles/vertical.frag"}
|
||||
]
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"minimum_tier": "DESKTOP_LITE",
|
||||
"requires_input_texture": true,
|
||||
"supported_backends": ["d3d11", "gl", "gles"]
|
||||
},
|
||||
"adaptive_quality": {
|
||||
"default": "auto",
|
||||
"variants": [
|
||||
{"id": "low", "internal_scale": 0.25, "samples": 5},
|
||||
{"id": "medium", "internal_scale": 0.5, "samples": 9},
|
||||
{"id": "high", "internal_scale": 1.0, "samples": 17}
|
||||
],
|
||||
"transition_ms": 180,
|
||||
"semantic_parameters_unchanged": ["radius"]
|
||||
},
|
||||
"parameters": [
|
||||
{
|
||||
"id": "radius",
|
||||
"label": "Radius",
|
||||
"type": "float",
|
||||
"minimum": 0.0,
|
||||
"maximum": 40.0,
|
||||
"default": 0.0,
|
||||
"dmx_slots": [1],
|
||||
"curve": "quadratic"
|
||||
},
|
||||
{
|
||||
"id": "quality",
|
||||
"label": "Quality",
|
||||
"type": "enum",
|
||||
"values": ["auto", "low", "medium", "high"],
|
||||
"default": "auto",
|
||||
"dmx_slots": [2]
|
||||
}
|
||||
],
|
||||
"failure_mode": "bypass"
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
// HMS MediaEngine – Gaussian Blur, horizontaler Pass (separable)
|
||||
// PLAN.md §14.3-Beispiel, §36 Nr. 10: drei Adaptive-Quality-Varianten
|
||||
// (low: 5 Samples, medium: 9, high: 17) werden vorab kompiliert; nur
|
||||
// u_quality_samples/internal_scale ändern sich, param_radius bleibt semantisch identisch.
|
||||
|
||||
Texture2D u_input_texture : register(t0);
|
||||
SamplerState u_sampler : register(s0);
|
||||
|
||||
cbuffer hms_params : register(b0)
|
||||
{
|
||||
float4 u_resolution; // xy = Auflösung in Pixeln
|
||||
float u_time_seconds;
|
||||
float u_delta_seconds;
|
||||
float u_frame_index;
|
||||
float u_layer_opacity;
|
||||
float u_audio_rms;
|
||||
float u_audio_peak;
|
||||
float u_audio_bass;
|
||||
float u_audio_mid;
|
||||
float u_audio_treble;
|
||||
float u_audio_beat;
|
||||
float param_radius; // 0..40 (quadratic curve, DMX P1)
|
||||
float u_quality_samples; // 5 | 9 | 17 je Variante (Backend-Bindung)
|
||||
float _pad0;
|
||||
float _pad1;
|
||||
};
|
||||
|
||||
float4 mainPS(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target
|
||||
{
|
||||
float radius = max(param_radius, 0.0);
|
||||
float4 src = u_input_texture.Sample(u_sampler, uv);
|
||||
if (radius < 0.01)
|
||||
{
|
||||
return src; // Radius 0 = kostenloser Bypass (§15.3)
|
||||
}
|
||||
|
||||
float samples = clamp(u_quality_samples, 1.0, 17.0);
|
||||
float stepSize = radius / max(samples - 1.0, 1.0);
|
||||
float2 texel = float2(1.0, 0.0) / u_resolution.xy;
|
||||
|
||||
float4 acc = float4(0.0, 0.0, 0.0, 0.0);
|
||||
float total = 0.0;
|
||||
[loop]
|
||||
for (float i = 0.0; i < samples; i += 1.0)
|
||||
{
|
||||
float t = i - (samples - 1.0) * 0.5;
|
||||
float w = exp(-(t * t) / (samples * 0.5));
|
||||
acc += u_input_texture.Sample(u_sampler, uv + texel * (t * stepSize)) * w;
|
||||
total += w;
|
||||
}
|
||||
return acc / total;
|
||||
}
|
||||
@@ -0,0 +1,50 @@
|
||||
// HMS MediaEngine – Gaussian Blur, vertikaler Pass (separable)
|
||||
// Identisch zu horizontal.hlsl mit texel = (0, 1).
|
||||
|
||||
Texture2D u_input_texture : register(t0);
|
||||
SamplerState u_sampler : register(s0);
|
||||
|
||||
cbuffer hms_params : register(b0)
|
||||
{
|
||||
float4 u_resolution;
|
||||
float u_time_seconds;
|
||||
float u_delta_seconds;
|
||||
float u_frame_index;
|
||||
float u_layer_opacity;
|
||||
float u_audio_rms;
|
||||
float u_audio_peak;
|
||||
float u_audio_bass;
|
||||
float u_audio_mid;
|
||||
float u_audio_treble;
|
||||
float u_audio_beat;
|
||||
float param_radius;
|
||||
float u_quality_samples;
|
||||
float _pad0;
|
||||
float _pad1;
|
||||
};
|
||||
|
||||
float4 mainPS(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_Target
|
||||
{
|
||||
float radius = max(param_radius, 0.0);
|
||||
float4 src = u_input_texture.Sample(u_sampler, uv);
|
||||
if (radius < 0.01)
|
||||
{
|
||||
return src;
|
||||
}
|
||||
|
||||
float samples = clamp(u_quality_samples, 1.0, 17.0);
|
||||
float stepSize = radius / max(samples - 1.0, 1.0);
|
||||
float2 texel = float2(0.0, 1.0) / u_resolution.xy;
|
||||
|
||||
float4 acc = float4(0.0, 0.0, 0.0, 0.0);
|
||||
float total = 0.0;
|
||||
[loop]
|
||||
for (float i = 0.0; i < samples; i += 1.0)
|
||||
{
|
||||
float t = i - (samples - 1.0) * 0.5;
|
||||
float w = exp(-(t * t) / (samples * 0.5));
|
||||
acc += u_input_texture.Sample(u_sampler, uv + texel * (t * stepSize)) * w;
|
||||
total += w;
|
||||
}
|
||||
return acc / total;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#version 330 core
|
||||
// HMS MediaEngine – Gaussian Blur, horizontaler Pass (GLSL, Linux x64)
|
||||
// Semantik identisch zur HLSL-Variante (§12.6, §15.4).
|
||||
|
||||
uniform sampler2D u_input_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time_seconds;
|
||||
uniform float u_delta_seconds;
|
||||
uniform float u_frame_index;
|
||||
uniform float u_layer_opacity;
|
||||
uniform float u_audio_rms;
|
||||
uniform float u_audio_peak;
|
||||
uniform float u_audio_bass;
|
||||
uniform float u_audio_mid;
|
||||
uniform float u_audio_treble;
|
||||
uniform float u_audio_beat;
|
||||
uniform float param_radius;
|
||||
uniform float u_quality_samples;
|
||||
|
||||
in vec2 v_uv;
|
||||
out vec4 fragColor;
|
||||
|
||||
void main()
|
||||
{
|
||||
float radius = max(param_radius, 0.0);
|
||||
vec4 src = texture(u_input_texture, v_uv);
|
||||
if (radius < 0.01)
|
||||
{
|
||||
fragColor = src;
|
||||
return;
|
||||
}
|
||||
|
||||
float samples = clamp(u_quality_samples, 1.0, 17.0);
|
||||
float stepSize = radius / max(samples - 1.0, 1.0);
|
||||
vec2 texel = vec2(1.0, 0.0) / u_resolution;
|
||||
|
||||
vec4 acc = vec4(0.0);
|
||||
float total = 0.0;
|
||||
for (float i = 0.0; i < samples; i += 1.0)
|
||||
{
|
||||
float t = i - (samples - 1.0) * 0.5;
|
||||
float w = exp(-(t * t) / (samples * 0.5));
|
||||
acc += texture(u_input_texture, v_uv + texel * (t * stepSize)) * w;
|
||||
total += w;
|
||||
}
|
||||
fragColor = acc / total;
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
#version 330 core
|
||||
// HMS MediaEngine – Gaussian Blur, vertikaler Pass (GLSL, Linux x64)
|
||||
|
||||
uniform sampler2D u_input_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time_seconds;
|
||||
uniform float u_delta_seconds;
|
||||
uniform float u_frame_index;
|
||||
uniform float u_layer_opacity;
|
||||
uniform float u_audio_rms;
|
||||
uniform float u_audio_peak;
|
||||
uniform float u_audio_bass;
|
||||
uniform float u_audio_mid;
|
||||
uniform float u_audio_treble;
|
||||
uniform float u_audio_beat;
|
||||
uniform float param_radius;
|
||||
uniform float u_quality_samples;
|
||||
|
||||
in vec2 v_uv;
|
||||
out vec4 fragColor;
|
||||
|
||||
void main()
|
||||
{
|
||||
float radius = max(param_radius, 0.0);
|
||||
vec4 src = texture(u_input_texture, v_uv);
|
||||
if (radius < 0.01)
|
||||
{
|
||||
fragColor = src;
|
||||
return;
|
||||
}
|
||||
|
||||
float samples = clamp(u_quality_samples, 1.0, 17.0);
|
||||
float stepSize = radius / max(samples - 1.0, 1.0);
|
||||
vec2 texel = vec2(0.0, 1.0) / u_resolution;
|
||||
|
||||
vec4 acc = vec4(0.0);
|
||||
float total = 0.0;
|
||||
for (float i = 0.0; i < samples; i += 1.0)
|
||||
{
|
||||
float t = i - (samples - 1.0) * 0.5;
|
||||
float w = exp(-(t * t) / (samples * 0.5));
|
||||
acc += texture(u_input_texture, v_uv + texel * (t * stepSize)) * w;
|
||||
total += w;
|
||||
}
|
||||
fragColor = acc / total;
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
#version 100
|
||||
// HMS MediaEngine – Gaussian Blur, horizontaler Pass (GLES, Raspberry Pi)
|
||||
// ES 2.0: Schleifen mit konstanter Höchstgrenze; Abbruch über Bedingung.
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_input_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time_seconds;
|
||||
uniform float u_delta_seconds;
|
||||
uniform float u_frame_index;
|
||||
uniform float u_layer_opacity;
|
||||
uniform float u_audio_rms;
|
||||
uniform float u_audio_peak;
|
||||
uniform float u_audio_bass;
|
||||
uniform float u_audio_mid;
|
||||
uniform float u_audio_treble;
|
||||
uniform float u_audio_beat;
|
||||
uniform float param_radius;
|
||||
uniform float u_quality_samples;
|
||||
|
||||
varying vec2 v_uv;
|
||||
|
||||
void main()
|
||||
{
|
||||
float radius = max(param_radius, 0.0);
|
||||
vec4 src = texture2D(u_input_texture, v_uv);
|
||||
if (radius < 0.01)
|
||||
{
|
||||
gl_FragColor = src;
|
||||
return;
|
||||
}
|
||||
|
||||
float samples = clamp(u_quality_samples, 1.0, 17.0);
|
||||
float stepSize = radius / max(samples - 1.0, 1.0);
|
||||
vec2 texel = vec2(1.0, 0.0) / u_resolution;
|
||||
|
||||
vec4 acc = vec4(0.0);
|
||||
float total = 0.0;
|
||||
for (int i = 0; i < 17; i++)
|
||||
{
|
||||
if (float(i) >= samples) { break; }
|
||||
float t = float(i) - (samples - 1.0) * 0.5;
|
||||
float w = exp(-(t * t) / (samples * 0.5));
|
||||
acc += texture2D(u_input_texture, v_uv + texel * (t * stepSize)) * w;
|
||||
total += w;
|
||||
}
|
||||
gl_FragColor = acc / total;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
#version 100
|
||||
// HMS MediaEngine – Gaussian Blur, vertikaler Pass (GLES, Raspberry Pi)
|
||||
precision mediump float;
|
||||
|
||||
uniform sampler2D u_input_texture;
|
||||
uniform vec2 u_resolution;
|
||||
uniform float u_time_seconds;
|
||||
uniform float u_delta_seconds;
|
||||
uniform float u_frame_index;
|
||||
uniform float u_layer_opacity;
|
||||
uniform float u_audio_rms;
|
||||
uniform float u_audio_peak;
|
||||
uniform float u_audio_bass;
|
||||
uniform float u_audio_mid;
|
||||
uniform float u_audio_treble;
|
||||
uniform float u_audio_beat;
|
||||
uniform float param_radius;
|
||||
uniform float u_quality_samples;
|
||||
|
||||
varying vec2 v_uv;
|
||||
|
||||
void main()
|
||||
{
|
||||
float radius = max(param_radius, 0.0);
|
||||
vec4 src = texture2D(u_input_texture, v_uv);
|
||||
if (radius < 0.01)
|
||||
{
|
||||
gl_FragColor = src;
|
||||
return;
|
||||
}
|
||||
|
||||
float samples = clamp(u_quality_samples, 1.0, 17.0);
|
||||
float stepSize = radius / max(samples - 1.0, 1.0);
|
||||
vec2 texel = vec2(0.0, 1.0) / u_resolution;
|
||||
|
||||
vec4 acc = vec4(0.0);
|
||||
float total = 0.0;
|
||||
for (int i = 0; i < 17; i++)
|
||||
{
|
||||
if (float(i) >= samples) { break; }
|
||||
float t = float(i) - (samples - 1.0) * 0.5;
|
||||
float w = exp(-(t * t) / (samples * 0.5));
|
||||
acc += texture2D(u_input_texture, v_uv + texel * (t * stepSize)) * w;
|
||||
total += w;
|
||||
}
|
||||
gl_FragColor = acc / total;
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
[project]
|
||||
name = "hms-mediaengine"
|
||||
version = "0.1.0"
|
||||
description = "HMS MediaEngine – modularer Medienserver, VJ-System und generativer Effektserver (Arbeitstitel)"
|
||||
requires-python = "==3.13.*"
|
||||
dependencies = [
|
||||
"fastapi>=0.115",
|
||||
"uvicorn>=0.30",
|
||||
"pydantic>=2.7",
|
||||
"msgpack>=1.0",
|
||||
]
|
||||
|
||||
[dependency-groups]
|
||||
dev = [
|
||||
"pytest>=8.2",
|
||||
"httpx>=0.27",
|
||||
"ruff>=0.6",
|
||||
]
|
||||
|
||||
[build-system]
|
||||
requires = ["hatchling>=1.22"]
|
||||
build-backend = "hatchling.build"
|
||||
|
||||
[tool.hatch.build.targets.wheel]
|
||||
packages = [
|
||||
"packages/protocol/hms_protocol",
|
||||
"packages/domain/hms_domain",
|
||||
"packages/parameter_engine/hms_parameter",
|
||||
"packages/artnet/hms_artnet",
|
||||
"packages/adaptive_quality/hms_adaptive",
|
||||
"packages/capabilities/hms_capabilities",
|
||||
"packages/plugin_sdk/hms_plugin_sdk",
|
||||
"apps/renderer/hms_renderer",
|
||||
"apps/control_server/hms_control_server",
|
||||
"apps/launcher/hms_launcher",
|
||||
]
|
||||
|
||||
[tool.pytest.ini_options]
|
||||
testpaths = ["tests"]
|
||||
addopts = "-q"
|
||||
|
||||
[tool.ruff]
|
||||
line-length = 100
|
||||
target-version = "py313"
|
||||
|
||||
[tool.ruff.lint]
|
||||
select = ["E", "F", "W", "I", "UP", "B"]
|
||||
@@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "hms/schemas/cluster/cluster_message_v1.schema.json",
|
||||
"title": "HMS Cluster Message v1",
|
||||
"description": "PLAN.md §6.5: Jede Cluster-Nachricht enthält mindestens cluster_id, node_id, command_id, Sequenz, Projekt-Revision, Absenderzeit und Trace-ID.",
|
||||
"type": "object",
|
||||
"required": ["cluster_id", "node_id", "command_id", "sequence", "project_revision", "sender_time_ns", "trace_id"],
|
||||
"properties": {
|
||||
"cluster_id": {"type": "string", "format": "uuid"},
|
||||
"node_id": {"type": "string", "format": "uuid"},
|
||||
"command_id": {"type": "string", "format": "uuid"},
|
||||
"sequence": {"type": "integer", "minimum": 0},
|
||||
"project_revision": {"type": "integer", "minimum": 0},
|
||||
"sender_time_ns": {"type": "integer", "minimum": 0},
|
||||
"trace_id": {"type": "string", "format": "uuid"},
|
||||
"execute_at_show_time_ns": {"type": ["integer", "null"], "minimum": 0},
|
||||
"status": {"enum": ["accepted", "armed", "executed", "failed"]},
|
||||
"payload": {"type": "object"}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "hms/schemas/ipc/envelope_v1.schema.json",
|
||||
"title": "HMS IPC Envelope v1",
|
||||
"description": "PLAN.md §6.2: Jede IPC-Nachricht besitzt mindestens diese Felder.",
|
||||
"type": "object",
|
||||
"required": ["protocol_version", "message_id", "type", "revision", "monotonic_timestamp_ns", "payload"],
|
||||
"properties": {
|
||||
"protocol_version": {"const": 1},
|
||||
"message_id": {"type": "string", "format": "uuid"},
|
||||
"type": {"enum": ["command", "event", "snapshot", "ack", "error", "telemetry"]},
|
||||
"revision": {"type": "integer", "minimum": 0},
|
||||
"monotonic_timestamp_ns": {"type": "integer", "minimum": 0},
|
||||
"payload": {"type": "object"},
|
||||
"idempotency_key": {"type": "string"}
|
||||
},
|
||||
"additionalProperties": false
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
{
|
||||
"$schema": "https://json-schema.org/draft/2020-12/schema",
|
||||
"$id": "hms/schemas/plugin/plugin_manifest_v1.schema.json",
|
||||
"title": "HMS Plugin Manifest v1",
|
||||
"description": "PLAN.md §14.3: Plugin-Manifest mit Backend-Entrypoints, Parameters und Adaptive Quality.",
|
||||
"type": "object",
|
||||
"required": ["schema_version", "id", "name", "version", "api_version", "kind", "vendor", "entrypoints", "capabilities", "failure_mode"],
|
||||
"properties": {
|
||||
"schema_version": {"const": 1},
|
||||
"id": {"type": "string", "pattern": "^[a-z0-9._-]{5,}$"},
|
||||
"name": {"type": "string", "minLength": 1},
|
||||
"version": {"type": "string", "pattern": "^\\d+\\.\\d+\\.\\d+$"},
|
||||
"api_version": {"const": 1},
|
||||
"kind": {"enum": ["source", "generator", "filter", "transition", "mixer", "output", "control", "automation"]},
|
||||
"vendor": {"type": "string", "minLength": 1},
|
||||
"entrypoints": {
|
||||
"type": "object",
|
||||
"minProperties": 1,
|
||||
"additionalProperties": {
|
||||
"type": "object",
|
||||
"required": ["type", "passes"],
|
||||
"properties": {
|
||||
"type": {"type": "string"},
|
||||
"passes": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {"type": "object"}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"capabilities": {
|
||||
"type": "object",
|
||||
"required": ["minimum_tier", "supported_backends"],
|
||||
"properties": {
|
||||
"minimum_tier": {"enum": ["DESKTOP_FULL", "DESKTOP_LITE", "PI_LITE", "HEADLESS_CONTROL"]},
|
||||
"requires_input_texture": {"type": "boolean"},
|
||||
"supported_backends": {
|
||||
"type": "array",
|
||||
"items": {"enum": ["d3d11", "gl", "gles"]},
|
||||
"minItems": 1
|
||||
}
|
||||
}
|
||||
},
|
||||
"adaptive_quality": {
|
||||
"type": "object",
|
||||
"required": ["default", "variants"],
|
||||
"properties": {
|
||||
"default": {"type": "string"},
|
||||
"variants": {
|
||||
"type": "array",
|
||||
"minItems": 1,
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id"],
|
||||
"properties": {
|
||||
"id": {"type": "string"},
|
||||
"internal_scale": {"type": "number", "exclusiveMinimum": 0, "maximum": 1},
|
||||
"samples": {"type": "integer", "minimum": 1}
|
||||
}
|
||||
}
|
||||
},
|
||||
"transition_ms": {"type": "number", "minimum": 0},
|
||||
"semantic_parameters_unchanged": {"type": "array", "items": {"type": "string"}}
|
||||
}
|
||||
},
|
||||
"parameters": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "object",
|
||||
"required": ["id", "label", "type", "default"],
|
||||
"properties": {
|
||||
"id": {"type": "string", "minLength": 1},
|
||||
"label": {"type": "string", "minLength": 1},
|
||||
"type": {"enum": ["float", "int", "enum", "bool", "color"]},
|
||||
"minimum": {"type": "number"},
|
||||
"maximum": {"type": "number"},
|
||||
"default": {},
|
||||
"values": {"type": "array"},
|
||||
"dmx_slots": {
|
||||
"type": "array",
|
||||
"maxItems": 8,
|
||||
"items": {"type": "integer", "minimum": 1, "maximum": 8}
|
||||
},
|
||||
"curve": {"type": "string"}
|
||||
}
|
||||
}
|
||||
},
|
||||
"failure_mode": {"enum": ["bypass", "hold", "black"]}
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user