Phase 2: Medien-Engine – Transport, Preload, Library, Content-Manifest
- PlaybackController (§12.5): Play/Pause/Stop/Retrigger, Loop/Once/ Ping-Pong mit In/Out-Fenstern, variable Geschwindigkeit ±4x (§16.6), Ende-Ereignis genau einmal je Pass, monotone Ereignis-Zeitbasis (§12.2) - PreloadSlot (§12.2): atomarer Clipwechsel preload->ready->commit; nicht bereites Medium wechselt nie (Policy beim Aufrufer) - MediaLibrary (§13.2/§13.3): Import mit SHA-256-Duplikaterkennung, Bank-Slots als explizite Show-Metadaten, fehlende Dateien markiert, Relink; Import laeuft im Control-Core-Worker, nie im Renderthread - ContentManifest (§6.4/§10.1): SHA-256 je Datei + resumierbare Chunk-Hashes, Verifikation gegen Veraenderung, Diff fuer Sync-Plan, deterministische Serialisierung mit Revision - 35 neue Unit-Tests; Gesamtsuite 258 gruen, Ruff gruen
This commit is contained in:
@@ -0,0 +1,196 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user