Files
hms-mediaengine/packages/media/hms_media/transport.py
T
HMS MediaEngine Agent d24fe3a963 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
2026-09-11 01:25:23 +02:00

197 lines
7.0 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""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