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