Renderer-Engine: Sync->Advance->Snapshot-Kette (§12, §11.4, §29.2)

- RenderEngine: orchestriert alle Quellen pro Frame mit fester
  Master-Bildrate (§12.2): Sync aus StateMirror, Playback-Advancement,
  unveraenderlicher FrameSnapshot, Telemetry (§28.2)
- SourceHandle: verbindet PlaybackController + PreloadSlot +
  Parameterpfade; Transport (Play/Pause/Stop/Retrigger) aus Mirror;
  Clip-Wechsel atomar ueber Preload/Commit (§12.2, §16.5)
- FrameSnapshot: unveränderlich pro Frame (§11.4): Parameter,
  Positionen, Transport-Zustaende, aktive Assets; halbe Zustaende
  ausgeschlossen
- RenderTelemetry: Frame-Index, FPS, Framezeit, aktive Layer,
  pending Preloads, Events (§25.4, §28.2)
- TransportState-Mapping: int-Werte 0/1/2 aus Mirror auf Enum
- 9 Unit-Tests: Frame-Snapshots, Play-Command, Positions-Advancement,
  atomarer Asset-Wechsel, Retrigger, Snapshot-Unveränderlichkeit,
  Telemetry, Multi-Source-Speed, Stopped-Verhalten
- Gesamtsuite 595 gruen, Ruff gruen
This commit is contained in:
HMS MediaEngine Agent
2026-09-11 08:34:13 +02:00
parent 2b1e6af6c0
commit 73da76f44a
3 changed files with 486 additions and 4 deletions
+13 -4
View File
@@ -1,11 +1,16 @@
"""hms_renderer Render-Worker (PLAN.md §6.1C, §12, §36 Nr. 46).
Python orchestriert native GStreamer-Komponenten; keine Pixelverarbeitung
in Python (§2.1, §33). Pipelines werden als gst-launch-Strings definiert
und auf dem Zielsystem ausgeführt/messbar. Der RemoteStateMirror hält den
über IPC übermittelten Showzustand (§6.2, §6.4).
"""
in Python (§2.1, §33). Der RemoteStateMirror hält den über IPC übermittelten
Showzustand (§6.2, §6.4); die RenderEngine verbindet Mirror, Playback und
Preload zu Frame-Snapshots (§11.4)."""
from hms_renderer.engine import (
FrameSnapshot,
RenderEngine,
RenderTelemetry,
SourceHandle,
)
from hms_renderer.pipelines import (
D3D11Pipeline,
DevGLPipeline,
@@ -23,4 +28,8 @@ __all__ = [
"gst_available",
"RemoteStateMirror",
"apply_envelope",
"RenderEngine",
"SourceHandle",
"FrameSnapshot",
"RenderTelemetry",
]
+268
View File
@@ -0,0 +1,268 @@
"""Renderer-Engine: verbindet StateMirror, Playback und Preload (§12, §6.4).
Der Rendergraph läuft in nativem GStreamer/Rust (ADR-0004); diese Engine
orchestriert:
- liest Showparameter aus dem RemoteStateMirror (§6.4)
- berechnet Playback-Positionen je Quelle (§12.5)
- verwaltet Preload-Slots für atomaren Clipwechsel (§12.2)
- bildet pro Frame einen unveränderlichen Parameter-Snapshot (§11.4)
- erzeugt Rendertelemetrie (§28.2)
Keine Pixelverarbeitung in Python (§33); die GPU-Pipeline wird über
Pipeline-Definitionen an GStreamer übergeben.
"""
from __future__ import annotations
import time
from dataclasses import dataclass
from hms_domain.model import TransportState
from hms_media import PlaybackController, PreloadSlot
from hms_renderer.state_mirror import RemoteStateMirror
@dataclass(frozen=True)
class FrameSnapshot:
"""Unveränderlicher Parameter-Snapshot für genau einen Frame (§11.4).
Der Rendergraph (Rust/GStreamer) erhält genau dieses Objekt pro Frame;
halbe Zustände sind ausgeschlossen.
"""
frame_index: int
monotonic_ns: int
state_revision: int
parameters: dict[str, float]
source_positions: dict[str, float] # source_id → normalisierte Position
source_states: dict[str, str] # source_id → TransportState
active_asset_ids: dict[str, str | None] # layer_key → asset_id (nach Commit)
@dataclass
class RenderTelemetry:
"""Rendertelemetrie je Frame (§28.2, §25.4)."""
frame_index: int = 0
fps: float = 0.0
frame_time_ms: float = 0.0
dropped_frames: int = 0
active_layers: int = 0
preload_pending: int = 0
source_events: int = 0
class SourceHandle:
"""Verwaltet eine Medienquelle im Renderer (§12.5, §12.2).
Verbindet PlaybackController mit Parameterpfaden und einem PreloadSlot:
- Transport-Befehle aus dem StateMirror steuern den Controller
- Der PreloadSlot wechselt Clips atomar bei Commit
"""
def __init__(
self,
source_id: str,
parameter_prefix: str,
fps: float = 60.0,
) -> None:
self.source_id = source_id
self.parameter_prefix = parameter_prefix
self.controller = PlaybackController(source_id=source_id)
self.preload = PreloadSlot()
self._active_asset_id: str | None = None
self._fps = fps
self._last_events: list = []
@property
def active_asset_id(self) -> str | None:
return self._active_asset_id
@property
def position(self) -> float:
return self.controller.position
@property
def state(self) -> TransportState:
return self.controller.state
def sync_from_mirror(self, mirror: RemoteStateMirror) -> list:
"""Liest Transport-Befehle und Parameter aus dem StateMirror.
Rückgabe: PlaybackEvents dieses Sync-Schritts (§12.5).
"""
events: list = []
# Transport-State aus dem Mirror lesen (§11.2)
state_value = mirror.get_value(f"{self.parameter_prefix}/source/state")
if state_value is not None:
state_map = {
0: TransportState.STOPPED,
1: TransportState.PLAYING,
2: TransportState.PAUSED,
}
target_state = state_map.get(int(state_value))
if target_state is not None:
is_playing = target_state is TransportState.PLAYING
is_paused = target_state is TransportState.PAUSED
is_stopped = target_state is TransportState.STOPPED
if is_playing and self.controller.state is not TransportState.PLAYING:
self.controller.play()
elif is_paused and self.controller.state is TransportState.PLAYING:
self.controller.pause()
elif is_stopped and self.controller.state is not TransportState.STOPPED:
self.controller.stop()
# Playback-Parameter aus dem Mirror (§10.2)
speed = mirror.get_value(f"{self.parameter_prefix}/source/speed")
if speed is not None and speed != 0:
self.controller.speed = max(-4.0, min(4.0, speed))
in_point = mirror.get_value(f"{self.parameter_prefix}/source/in_point")
if in_point is not None:
self.controller.in_point = max(0.0, min(0.99, in_point))
out_point = mirror.get_value(f"{self.parameter_prefix}/source/out_point")
if out_point is not None:
self.controller.out_point = max(self.controller.in_point + 0.01, min(1.0, out_point))
# Retrigger: Flankenwert im Mirror (1.0 = triggern, danach zurück auf 0)
retrigger = mirror.get_value(f"{self.parameter_prefix}/source/retrigger")
if retrigger is not None and retrigger > 0.5:
self.controller.retrigger()
# Clip-Auswahl über PreloadSlot (§12.2, §16.5 Load/Commit)
pending_asset = mirror.get_value(f"{self.parameter_prefix}/source/asset_id_pending")
if pending_asset is not None and pending_asset > 0:
# Asset-ID ist als Hash/Integer im Mirror; real: UUID-String aus Registry
# Hier: Asset-Wechsel nur über Commit (PreloadSlot)
self.preload.preload(str(int(pending_asset)))
commit = mirror.get_value(f"{self.parameter_prefix}/source/commit")
if commit is not None and commit > 0.5:
self.preload.mark_ready(str(int(mirror.get_value(
f"{self.parameter_prefix}/source/asset_id_pending", 0
))))
committed = self.preload.commit()
if committed:
self._active_asset_id = committed # atomar gewechselt (§12.2)
self._last_events = events
return events
def advance(self, dt_s: float, now_ns: int) -> list:
"""Advancement des PlaybackControllers; liefert Events."""
return self.controller.advance(dt_s, now_ns)
class RenderEngine:
"""Zentrale Renderer-Engine: orchestriert alle Quellen pro Frame.
Ablauf pro Frame (§12.2: feste Master-Bildrate):
1. sync_from_mirror: Transport-Parameter aus dem StateMirror lesen
2. advance: Playback-Positionen um dt weiterschieben
3. snapshot: unveränderlichen Frame-Snapshot bilden
4. telemetry: Frame-Statistiken aktualisieren
"""
def __init__(self, mirror: RemoteStateMirror, fps: float = 60.0) -> None:
self._mirror = mirror
self._fps = fps
self._frame_duration_s = 1.0 / fps
self._sources: dict[str, SourceHandle] = {}
self._frame_index = 0
self._last_frame_ns = 0
self.telemetry = RenderTelemetry()
@property
def mirror(self) -> RemoteStateMirror:
return self._mirror
@property
def fps(self) -> float:
return self._fps
@property
def frame_index(self) -> int:
return self._frame_index
def add_source(self, source_id: str, parameter_prefix: str) -> SourceHandle:
"""Registriert eine Medienquelle im Renderer."""
handle = SourceHandle(source_id, parameter_prefix, self._fps)
self._sources[source_id] = handle
return handle
def remove_source(self, source_id: str) -> None:
self._sources.pop(source_id, None)
def get_source(self, source_id: str) -> SourceHandle | None:
return self._sources.get(source_id)
def sources(self) -> list[SourceHandle]:
return list(self._sources.values())
def tick(self, now_ns: int | None = None) -> FrameSnapshot:
"""Ein Frame: Sync → Advance → Snapshot.
Wird vom Renderer-Loop mit fester Master-Bildrate aufgerufen (§12.2).
"""
now = now_ns if now_ns is not None else time.monotonic_ns()
if self._last_frame_ns == 0:
self._last_frame_ns = now
dt_s = (now - self._last_frame_ns) / 1e9
self._last_frame_ns = now
# 1. Sync: Parameter aus dem Mirror lesen
all_events: list = []
for handle in self._sources.values():
handle.sync_from_mirror(self._mirror)
# 2. Advance: Playback weiterschieben
for handle in self._sources.values():
events = handle.advance(dt_s, now)
all_events.extend(events)
# 3. Snapshot: unveränderlicher Frame-Zustand (§11.4)
self._frame_index += 1
parameters = self._mirror.all_values()
source_positions: dict[str, float] = {}
source_states: dict[str, str] = {}
active_assets: dict[str, str | None] = {}
for source_id, handle in self._sources.items():
source_positions[source_id] = handle.position
source_states[source_id] = handle.state.value
active_assets[f"{handle.parameter_prefix}/source"] = handle.active_asset_id
snapshot = FrameSnapshot(
frame_index=self._frame_index,
monotonic_ns=now,
state_revision=self._mirror.revision,
parameters=parameters,
source_positions=source_positions,
source_states=source_states,
active_asset_ids=active_assets,
)
# 4. Telemetry (§28.2)
frame_time_ms = dt_s * 1000.0
active_count = sum(
1 for h in self._sources.values()
if h.state is TransportState.PLAYING
)
preload_count = sum(
1 for h in self._sources.values()
if h.preload.pending is not None
)
self.telemetry = RenderTelemetry(
frame_index=self._frame_index,
fps=1000.0 / max(frame_time_ms, 0.01),
frame_time_ms=frame_time_ms,
dropped_frames=0, # zählt der native Rendergraph
active_layers=active_count,
preload_pending=preload_count,
source_events=len(all_events),
)
return snapshot