166 lines
5.9 KiB
Python
166 lines
5.9 KiB
Python
|
|
"""Autoritativer Projekt- und Showzustand (PLAN.md §6.4 State Sync, §24.2).
|
|||
|
|
|
|||
|
|
- Vollständiger Snapshot nach Verbindung; danach inkrementelle Deltas
|
|||
|
|
- monotone Revisionen: kein halber Zustand (§11.4, §6.4)
|
|||
|
|
- Livezustand und dauerhafter Projektzustand sind getrennt (§24.2)
|
|||
|
|
- Szenenaktivierung erzeugt eine neue State-Revision
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
from __future__ import annotations
|
|||
|
|
|
|||
|
|
import time
|
|||
|
|
from dataclasses import dataclass, field
|
|||
|
|
|
|||
|
|
from hms_domain.model import PresetScene, Project
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass(frozen=True)
|
|||
|
|
class StateDelta:
|
|||
|
|
"""Inkrementelle Änderung mit monotoner Revision (§6.4).
|
|||
|
|
|
|||
|
|
- project_revision: Projekt-Inhaltsrevision (Manifest-Ebene)
|
|||
|
|
- state_revision: monotone Showzustands-Revision
|
|||
|
|
- changes: Parameterpfad → Wert; gelöschte Pfade als None markiert
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
state_revision: int
|
|||
|
|
project_revision: int
|
|||
|
|
changes: dict[str, float | None]
|
|||
|
|
monotonic_ns: int
|
|||
|
|
|
|||
|
|
def to_dict(self) -> dict:
|
|||
|
|
return {
|
|||
|
|
"state_revision": self.state_revision,
|
|||
|
|
"project_revision": self.project_revision,
|
|||
|
|
"changes": self.changes,
|
|||
|
|
"monotonic_ns": self.monotonic_ns,
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
|
|||
|
|
@dataclass
|
|||
|
|
class ProjectStateStore:
|
|||
|
|
"""Autoritative Instanz im Control Core (§6.1B, §6.4).
|
|||
|
|
|
|||
|
|
Trennung (§24.2):
|
|||
|
|
- project: dauerhafter Projektzustand (Domänenmodell, persistiert)
|
|||
|
|
- live: Show-Livezustand (Parameterwerte je Pfad, nicht persistent)
|
|||
|
|
|
|||
|
|
Revisions:
|
|||
|
|
- state_revision steigt bei jeder Livezustandsänderung monoton
|
|||
|
|
- project_revision steigt bei Projektinhalts-Änderungen (z. B. neue
|
|||
|
|
Szenen, Medien-Revision) – Grundlage für Preflight (§6.5)
|
|||
|
|
"""
|
|||
|
|
|
|||
|
|
_state_revision: int = 0
|
|||
|
|
_project_revision: int = 0
|
|||
|
|
_live: dict[str, float] = field(default_factory=dict)
|
|||
|
|
_project: Project | None = None
|
|||
|
|
_project_name: str = ""
|
|||
|
|
|
|||
|
|
# ---------- Projekt ----------
|
|||
|
|
|
|||
|
|
def activate_project(self, project: Project) -> int:
|
|||
|
|
"""Aktiviert ein Projekt als autoritative Basis; erhöht die
|
|||
|
|
Projektrevision. Livezustand wird zurückgesetzt (kein Mischzustand)."""
|
|||
|
|
self._project = project
|
|||
|
|
self._project_name = project.name
|
|||
|
|
self._project_revision += 1
|
|||
|
|
self._live.clear()
|
|||
|
|
self._state_revision += 1 # neuer Zustand nach Projektwechsel
|
|||
|
|
return self._state_revision
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def project(self) -> Project | None:
|
|||
|
|
return self._project
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def project_revision(self) -> int:
|
|||
|
|
return self._project_revision
|
|||
|
|
|
|||
|
|
@property
|
|||
|
|
def state_revision(self) -> int:
|
|||
|
|
return self._state_revision
|
|||
|
|
|
|||
|
|
# ---------- Livezustand ----------
|
|||
|
|
|
|||
|
|
def set_value(self, path: str, value: float) -> int:
|
|||
|
|
"""Setzt einen Liveparameter; gibt die neue State-Revision zurück."""
|
|||
|
|
self._live[path] = float(value)
|
|||
|
|
self._state_revision += 1
|
|||
|
|
return self._state_revision
|
|||
|
|
|
|||
|
|
def clear_value(self, path: str) -> int:
|
|||
|
|
"""Entfernt einen Liveparameter (z. B. Release); neue Revision."""
|
|||
|
|
self._live.pop(path, None)
|
|||
|
|
self._state_revision += 1
|
|||
|
|
return self._state_revision
|
|||
|
|
|
|||
|
|
def get_value(self, path: str) -> float | None:
|
|||
|
|
return self._live.get(path)
|
|||
|
|
|
|||
|
|
# ---------- Snapshot / Delta (§6.4) ----------
|
|||
|
|
|
|||
|
|
def snapshot(self) -> dict:
|
|||
|
|
"""Vollständiger Zustand nach Verbindungsaufbau (§6.2, §6.4)."""
|
|||
|
|
return {
|
|||
|
|
"state_revision": self._state_revision,
|
|||
|
|
"project_revision": self._project_revision,
|
|||
|
|
"project_name": self._project_name,
|
|||
|
|
"values": dict(self._live),
|
|||
|
|
"monotonic_ns": time.monotonic_ns(),
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
def delta_since(self, last_seen_revision: int, pending: dict[str, float]) -> StateDelta | None:
|
|||
|
|
"""Delta seit einer gesehenen Revision; None, wenn nichts Neues.
|
|||
|
|
|
|||
|
|
pending: letzter BEKANNTER Zustand des Empfängers (Pfad → Wert,
|
|||
|
|
wie er bei last_seen_revision beim Client stand). Das Delta enthält:
|
|||
|
|
- neue Pfade (in live, nicht in pending) mit ihrem Wert
|
|||
|
|
- geänderte Pfade mit dem neuen Wert
|
|||
|
|
- gelöschte Pfade als None
|
|||
|
|
Ein vollständiger Re-Sync (neuer Snapshot) ist Aufgabe des
|
|||
|
|
Transports, wenn last_seen_revision zu alt ist (§6.2).
|
|||
|
|
"""
|
|||
|
|
if last_seen_revision > self._state_revision:
|
|||
|
|
raise ValueError(
|
|||
|
|
f"gesehene Revision {last_seen_revision} liegt in der Zukunft"
|
|||
|
|
)
|
|||
|
|
if last_seen_revision == self._state_revision:
|
|||
|
|
return None # nichts Neues
|
|||
|
|
changes: dict[str, float | None] = {}
|
|||
|
|
for path, value in self._live.items():
|
|||
|
|
if path not in pending:
|
|||
|
|
changes[path] = value # neu seit last_seen
|
|||
|
|
elif pending[path] != value:
|
|||
|
|
changes[path] = value # geändert
|
|||
|
|
for path in pending:
|
|||
|
|
if path not in self._live:
|
|||
|
|
changes[path] = None # gelöscht
|
|||
|
|
return StateDelta(
|
|||
|
|
state_revision=self._state_revision,
|
|||
|
|
project_revision=self._project_revision,
|
|||
|
|
changes=changes,
|
|||
|
|
monotonic_ns=time.monotonic_ns(),
|
|||
|
|
)
|
|||
|
|
|
|||
|
|
# ---------- Szenen (§18) ----------
|
|||
|
|
|
|||
|
|
def apply_scene(self, scene: PresetScene) -> int:
|
|||
|
|
"""Aktiviert eine Szene direkt (§18.1): ÜBERNAHME der Snapshot-Werte
|
|||
|
|
in den Livezustand als neue State-Revision. Übergänge (Crossfade
|
|||
|
|
etc.) berechnet der Renderer aus vorher/nachher – hier entsteht nur
|
|||
|
|
der Zielzustand (§18.1: Diff zur Laufzeit).
|
|||
|
|
"""
|
|||
|
|
snapshot_values = scene.composition_snapshot.get("values", {})
|
|||
|
|
if not isinstance(snapshot_values, dict):
|
|||
|
|
raise ValueError("Szene enthält keine Werte")
|
|||
|
|
for path, value in snapshot_values.items():
|
|||
|
|
self._live[str(path)] = float(value)
|
|||
|
|
self._state_revision += 1
|
|||
|
|
return self._state_revision
|
|||
|
|
|
|||
|
|
def bump_project_revision(self) -> int:
|
|||
|
|
"""Projektinhalt geändert (Medien/Plugins/Szenen) → neue Revision."""
|
|||
|
|
self._project_revision += 1
|
|||
|
|
return self._project_revision
|