362e089be0
Der Nutzer hat recht: Der Ordner war voller Entwicklungs-Muell. Jetzt ist sauber getrennt: ROOT (was der Nutzer sieht und braucht): - run.py = das Programm - hms_app/ = der Anwendungscode - HMS MediaEngine.app = macOS Doppelklick-Starter - HMS-Start.vbs = Windows Doppelklick-Starter - HMS-Install.vbs = Windows Erst-Installation - HMS-Mac-Install.command = macOS Homebrew-Installation - HMS-Portable-Install.command = macOS Portable-Installation (16GB-Fix) - installer_gui.py = grafischer Installer - launcher.pyw + launcher_core.py = interne Start-Logik - LIESMICH.txt = 10-Zeilen-Kurzanleitung - .gitignore _entwicklung/ (alles andere, NICHT benoetigt): - packages/ apps/ native/ plugins/ tools/ schemas/ tests/ docs/ build/ fixture_profiles/ - PLAN.md STATUS.md ERRORS.md TEST_REPORT.md CHANGELOG.md README.md - pyproject.toml uv.lock setup_*.sh/ps1 make_mac_app.py Diese Trennung gilt ab sofort fuer alle Commits. Der Nutzer kann _entwicklung/ loeschen wenn er Platz braucht - die App laeuft ohne. Verifiziert: App startet nach Aufraeumen unveraendert (Health 200).
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
|