Files
hms-mediaengine/packages/persistence/hms_persistence/state_store.py
T

166 lines
5.9 KiB
Python
Raw Normal View History

"""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