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:
@@ -1,11 +1,16 @@
|
||||
"""hms_renderer – Render-Worker (PLAN.md §6.1C, §12, §36 Nr. 4–6).
|
||||
|
||||
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",
|
||||
]
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user