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
+205
View File
@@ -0,0 +1,205 @@
"""Tests Renderer-Engine: Sync→Advance→Snapshot-Kette (§12, §11.4, §29.2)."""
from __future__ import annotations
import pytest
from hms_domain import TransportState
from hms_renderer import FrameSnapshot, RemoteStateMirror, RenderEngine
def _mirror_with_params(params: dict[str, float]) -> RemoteStateMirror:
"""Mirror mit Snapshot, der die gegebenen Parameter enthält."""
mirror = RemoteStateMirror()
mirror.apply_snapshot({
"state_revision": 1,
"project_revision": 1,
"values": params,
"monotonic_ns": 0,
})
return mirror
def test_engine_creates_frame_snapshot_per_tick() -> None:
"""tick() erzeugt FrameSnapshots mit steigendem Index (§11.4)."""
mirror = _mirror_with_params({"master/intensity": 1.0})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-1", "composition/a/layer/b")
t0 = 1_000_000_000
t1 = t0 + 16_666_667 # ~60 fps (16.67 ms)
snap1 = engine.tick(t0)
snap2 = engine.tick(t1)
assert isinstance(snap1, FrameSnapshot)
assert snap1.frame_index == 1
assert snap2.frame_index == 2
assert snap2.parameters["master/intensity"] == 1.0
def test_engine_syncs_play_command_from_mirror() -> None:
"""Mirror-Parameter 'source/state' steuert den Transport (§12.5)."""
# TransportState.PLAYING = 1.0 im Mirror
mirror = _mirror_with_params({
"layer/x/source/state": 1.0,
"layer/x/source/speed": 1.0,
})
engine = RenderEngine(mirror, fps=60.0)
handle = engine.add_source("src-1", "layer/x")
assert handle.state is TransportState.STOPPED
engine.tick(1_000_000_000)
assert handle.state is TransportState.PLAYING
# zweiter Frame: Position schreitet voran
engine.tick(1_016_666_667)
assert handle.position > 0.0
def test_engine_advances_position_over_time() -> None:
"""Playback-Position wächst mit der Frame-Zeit (§12.5)."""
mirror = _mirror_with_params({
"layer/x/source/state": 1.0,
"layer/x/source/speed": 1.0,
})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-1", "layer/x")
t0 = 1_000_000_000
engine.tick(t0)
# 10 Frames später à 16.67 ms = ~0.167 s Advancement
for i in range(10):
engine.tick(t0 + (i + 1) * 16_666_667)
handle = engine.get_source("src-1")
assert handle is not None
assert 0.1 < handle.position < 0.25 # ca. 10/60 = 0.167
def test_engine_preload_commit_switches_asset_atomically() -> None:
"""Clip-Wechsel über Preload: pending → ready → commit (§12.2)."""
mirror = _mirror_with_params({})
engine = RenderEngine(mirror, fps=60.0)
handle = engine.add_source("src-1", "layer/x")
# Asset auswählen (pending)
mirror.apply_delta({
"state_revision": 2,
"project_revision": 1,
"changes": {"layer/x/source/asset_id_pending": 42.0},
"monotonic_ns": 0,
})
engine.tick(1_000_000_000)
assert handle.preload.pending == "42"
assert handle.active_asset_id is None # noch nicht gewechselt
# Commit (ready + Flanke)
mirror.apply_delta({
"state_revision": 3,
"project_revision": 1,
"changes": {"layer/x/source/commit": 1.0},
"monotonic_ns": 0,
})
snap = engine.tick(1_016_666_667)
assert handle.active_asset_id == "42" # atomar gewechselt
assert snap.active_asset_ids["layer/x/source"] == "42"
def test_engine_retrigger_resets_position() -> None:
"""Retrigger-Flanke startet die Quelle neu (§12.5)."""
mirror = _mirror_with_params({
"layer/x/source/state": 1.0,
"layer/x/source/speed": 1.0,
})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-1", "layer/x")
# 5 Frames weit spielen
t0 = 1_000_000_000
for i in range(5):
engine.tick(t0 + i * 16_666_667)
handle = engine.get_source("src-1")
assert handle is not None and handle.position > 0.05
# Retrigger
mirror.apply_delta({
"state_revision": 2,
"project_revision": 1,
"changes": {"layer/x/source/retrigger": 1.0},
"monotonic_ns": 0,
})
pos_before = handle.position
engine.tick(t0 + 5 * 16_666_667)
# Nach Retrigger: Position deutlich kleiner als davor (Rücksetzung auf
# In-Point im selben Frame; ein Frame dt advancement ist erlaubt)
assert handle.position < pos_before * 0.5
def test_engine_snapshot_is_immutable_per_frame() -> None:
"""§11.4: Frame-Snapshot bleibt stabil, auch wenn sich der Mirror ändert."""
mirror = _mirror_with_params({"master/intensity": 0.5})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-1", "layer/x")
snap = engine.tick(1_000_000_000)
intensity_at_snap = snap.parameters["master/intensity"]
# Mirror ändert sich nach dem Snapshot
mirror.apply_delta({
"state_revision": 2,
"project_revision": 1,
"changes": {"master/intensity": 0.9},
"monotonic_ns": 0,
})
# alter Snapshot unverändert
assert snap.parameters["master/intensity"] == pytest.approx(intensity_at_snap)
# neuer Frame hat den neuen Wert
snap2 = engine.tick(1_016_666_667)
assert snap2.parameters["master/intensity"] == pytest.approx(0.9)
def test_engine_telemetry_reports_frame_stats() -> None:
"""Telemetry enthält Frame-Index, FPS, aktive Layer (§28.2)."""
mirror = _mirror_with_params({"layer/x/source/state": 1.0})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-1", "layer/x")
engine.tick(1_000_000_000)
engine.tick(1_016_666_667) # 16.67 ms → ~60 fps
assert engine.telemetry.frame_index == 2
assert 50.0 < engine.telemetry.fps < 70.0 # ~60 fps
assert engine.telemetry.active_layers == 1 # src-1 spielt
def test_engine_multiple_sources_advance_independently() -> None:
"""Zwei Quellen mit unterschiedlicher Geschwindigkeit (§12.5)."""
mirror = _mirror_with_params({
"layer/a/source/state": 1.0,
"layer/a/source/speed": 2.0, # doppelt
"layer/b/source/state": 1.0,
"layer/b/source/speed": 0.5, # halb
})
engine = RenderEngine(mirror, fps=60.0)
engine.add_source("src-a", "layer/a")
engine.add_source("src-b", "layer/b")
t0 = 1_000_000_000
for i in range(20):
engine.tick(t0 + i * 16_666_667)
handle_a = engine.get_source("src-a")
handle_b = engine.get_source("src-b")
assert handle_a is not None and handle_b is not None
# src-a (2x) hat ~4x die Position von src-b (0.5x)
assert handle_a.position > handle_b.position * 3.5
def test_engine_stopped_source_does_not_advance() -> None:
"""Gestoppte Quelle bleibt bei In-Point (§12.5)."""
mirror = _mirror_with_params({}) # kein state → STOPPED
engine = RenderEngine(mirror, fps=60.0)
handle = engine.add_source("src-1", "layer/x")
for i in range(10):
engine.tick(1_000_000_000 + i * 16_666_667)
assert handle.state is TransportState.STOPPED
assert handle.position == pytest.approx(handle.controller.in_point)