"""Playback-Transportmodell und atomarer Clipwechsel (§12.2, §12.5). Reine Steuerungslogik ohne Pixelverarbeitung (§33): Positions-, Loop- und Trigger-Berechnung sind plattformneutral und werden vom Renderer pro Frame ausgeführt. Ping-Pong ist modellseitig verfügbar – ob ein Medium es performant kann, entscheidet die Codec-Eignung (MediaAsset.seek_suitability, §12.5); ungeeignete Medien werden sichtbar gemeldet, nicht still verlangsamt. """ from __future__ import annotations import time from dataclasses import dataclass from enum import StrEnum from hms_domain.model import LoopMode, TransportState class PlaybackEventKind(StrEnum): """Ereignisse je Advancement-Schritt (§12.5 Ende-Ereignis).""" END_OF_MEDIA = "end_of_media" LOOP_WRAP = "loop_wrap" DIRECTION_CHANGE = "direction_change" @dataclass(frozen=True) class PlaybackEvent: """Ein Wiedergabe-Ereignis mit Position und monotone Zeit.""" kind: PlaybackEventKind source_id: str position: float monotonic_ns: int class PlaybackController: """Transport-Zustandsmaschine für genau eine Quelle (§12.5). - play/pause/stop/retrigger - Position normalisiert 0..1, In-/Out-Punkte aus Source (§10.1) - Loop, Once, Ping-Pong - variable Geschwindigkeit; negativ = rückwärts - advance(dt) liefert Ereignisse; Medienende genau einmal je Pass Kein interner Timer: der Renderer ruft advance(dt) pro Frame mit der gemeinsamen monotonen Zeitbasis (§12.2) auf. """ def __init__( self, source_id: str, in_point: float = 0.0, out_point: float = 1.0, loop_mode: LoopMode = LoopMode.LOOP, speed: float = 1.0, ) -> None: if not 0.0 <= in_point < out_point <= 1.0: raise ValueError("in_point muss kleiner als out_point im Bereich 0..1 sein") self.source_id = source_id self.in_point = in_point self.out_point = out_point self.loop_mode = loop_mode self.state = TransportState.STOPPED self.position = in_point self.direction = 1 # +1 vorwärts, -1 rückwärts (Ping-Pong) self.speed = speed # ---------- Transportbefehle (§12.5) ---------- def play(self) -> None: self.state = TransportState.PLAYING def pause(self) -> None: if self.state is TransportState.PLAYING: self.state = TransportState.PAUSED def stop(self) -> None: """Stop: Transport angehalten, Position zurück auf In-Point.""" self.state = TransportState.STOPPED self.position = self.in_point self.direction = 1 def retrigger(self) -> None: """Neustart ab In-Point (flankenbasiert getriggert, §16.5).""" self.position = self.in_point self.direction = 1 self.state = TransportState.PLAYING def seek(self, position: float) -> None: if not self.in_point <= position <= self.out_point: raise ValueError(f"seek außerhalb In/Out: {position}") self.position = position def set_speed(self, speed: float) -> None: if speed == 0.0: raise ValueError("speed 0 unzulässig; pause() verwenden") self.speed = speed # ---------- Frame-Advancement ---------- def advance(self, dt_s: float, now_ns: int | None = None) -> list[PlaybackEvent]: """Advancement um dt Sekunden; liefert Ereignisse dieses Schritts. Wird nur im PLAYING-Zustand wirksam. Grenzüberschreitungen werden gemäß Loop-Modus behandelt; die Position bleibt immer innerhalb [in_point, out_point]. """ if dt_s < 0.0: raise ValueError("dt_s darf nicht negativ sein") if self.state is not TransportState.PLAYING or dt_s == 0.0: return [] now = now_ns if now_ns is not None else time.monotonic_ns() events: list[PlaybackEvent] = [] span = self.out_point - self.in_point new_pos = self.position + self.direction * self.speed * dt_s if self.loop_mode is LoopMode.LOOP: if new_pos > self.out_point: new_pos = self.in_point + (new_pos - self.out_point) % span events.append(self._event(PlaybackEventKind.LOOP_WRAP, new_pos, now)) elif new_pos < self.in_point: overshoot = (self.in_point - new_pos) % span new_pos = self.out_point - overshoot if overshoot else self.in_point events.append(self._event(PlaybackEventKind.LOOP_WRAP, new_pos, now)) elif self.loop_mode is LoopMode.ONCE: if new_pos >= self.out_point or new_pos <= self.in_point: new_pos = self.out_point if self.direction > 0 else self.in_point self.state = TransportState.STOPPED events.append(self._event(PlaybackEventKind.END_OF_MEDIA, new_pos, now)) else: # PING_PONG if self.direction > 0 and new_pos >= self.out_point: new_pos = max(self.out_point - (new_pos - self.out_point), self.in_point) self.direction = -1 events.append(self._event(PlaybackEventKind.DIRECTION_CHANGE, new_pos, now)) elif self.direction < 0 and new_pos <= self.in_point: new_pos = min(self.in_point + (self.in_point - new_pos), self.out_point) self.direction = 1 events.append(self._event(PlaybackEventKind.DIRECTION_CHANGE, new_pos, now)) self.position = new_pos return events def _event( self, kind: PlaybackEventKind, position: float, now_ns: int ) -> PlaybackEvent: return PlaybackEvent( kind=kind, source_id=self.source_id, position=position, monotonic_ns=now_ns, ) class PreloadSlot: """Vorgepufferte, atomar umschaltbare Clipauswahl (§12.2, §16.5). Ablauf: preload() → Renderer bereitet Decoder vor → mark_ready() → commit() wechselt an der Framegrenze atomar um. Ist beim Commit nicht ready, bleibt das alte Bild aktiv (Policy des Aufrufers: halten/warten/ Fallback, §18.3) – niemals ein halb geladenes Medium. """ def __init__(self) -> None: self._pending: str | None = None # asset_id self._ready: bool = False @property def pending(self) -> str | None: return self._pending @property def ready(self) -> bool: return self._ready def preload(self, asset_id: str) -> None: self._pending = asset_id self._ready = False def mark_ready(self, asset_id: str) -> None: if asset_id != self._pending: raise ValueError("mark_ready für anderes Asset als pending") self._ready = True def commit(self) -> str | None: """Atomarer Wechsel: gibt das Asset zurück oder None (nicht bereit).""" if self._pending is not None and self._ready: asset_id = self._pending self._pending = None self._ready = False return asset_id return None def cancel(self) -> None: self._pending = None self._ready = False