197 lines
7.0 KiB
Python
197 lines
7.0 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass, field
|
|
import time
|
|
|
|
from .models import FrameSnapshot, MappingSpec, SceneState
|
|
from .output.ddp import DDPClient
|
|
from .renderer import FastRenderer
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class OutputSettings:
|
|
output_fps: float = 40.0
|
|
preview_fps: float = 12.0
|
|
keepalive_seconds: float = 0.35
|
|
delta_sending: bool = True
|
|
preview_enabled: bool = True
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class RuntimeDiagnostics:
|
|
tile_count: int
|
|
controller_count: int
|
|
total_leds: int
|
|
output_frames: int = 0
|
|
preview_frames: int = 0
|
|
packets_built: int = 0
|
|
packets_sent: int = 0
|
|
changed_controller_frames: int = 0
|
|
keepalive_controller_frames: int = 0
|
|
stale_output_frames: int = 0
|
|
last_render_ms: float = 0.0
|
|
last_tick_at: float = 0.0
|
|
last_output_at: float = 0.0
|
|
last_preview_at: float = 0.0
|
|
|
|
|
|
@dataclass(slots=True)
|
|
class TickResult:
|
|
timestamp: float
|
|
output_due: bool
|
|
preview_due: bool
|
|
output_snapshot: FrameSnapshot | None = None
|
|
preview_snapshot: FrameSnapshot | None = None
|
|
changed_payloads: dict[str, bytes] = field(default_factory=dict)
|
|
packet_count: int = 0
|
|
|
|
|
|
class RealtimeEngine:
|
|
def __init__(
|
|
self,
|
|
mapping: MappingSpec,
|
|
scene: SceneState | None = None,
|
|
settings: OutputSettings | None = None,
|
|
ddp_client: DDPClient | None = None,
|
|
) -> None:
|
|
self.mapping = mapping
|
|
self.scene = scene or SceneState()
|
|
self.settings = settings or OutputSettings()
|
|
self.renderer = FastRenderer(mapping)
|
|
self.ddp_client = ddp_client or DDPClient()
|
|
|
|
renderer_stats = self.renderer.stats()
|
|
self.diagnostics = RuntimeDiagnostics(
|
|
tile_count=renderer_stats.tile_count,
|
|
controller_count=renderer_stats.controller_count,
|
|
total_leds=renderer_stats.total_leds,
|
|
)
|
|
|
|
self._last_output_at: float | None = None
|
|
self._last_preview_at: float | None = None
|
|
self._last_sent_payloads: dict[str, bytes] = {}
|
|
self._last_sent_at: dict[str, float] = {}
|
|
self._latest_snapshot: FrameSnapshot | None = None
|
|
self._force_output = True
|
|
self._force_preview = True
|
|
|
|
def set_scene(self, scene: SceneState) -> None:
|
|
self.scene = scene
|
|
self._force_output = True
|
|
self._force_preview = True
|
|
|
|
def request_preview_refresh(self) -> None:
|
|
self._force_preview = True
|
|
|
|
def latest_snapshot(self) -> FrameSnapshot | None:
|
|
return self._latest_snapshot
|
|
|
|
def tick(self, now: float | None = None) -> TickResult:
|
|
timestamp = time.perf_counter() if now is None else float(now)
|
|
output_due = self._is_due(self._last_output_at, self._interval(self.settings.output_fps), timestamp, self._force_output)
|
|
preview_due = self.settings.preview_enabled and self._is_due(
|
|
self._last_preview_at,
|
|
self._interval(self.settings.preview_fps),
|
|
timestamp,
|
|
self._force_preview,
|
|
)
|
|
if not output_due and not preview_due:
|
|
return TickResult(timestamp=timestamp, output_due=False, preview_due=False)
|
|
|
|
render_started = time.perf_counter()
|
|
snapshot = self.renderer.render(self.scene, timestamp=timestamp)
|
|
self.diagnostics.last_render_ms = (time.perf_counter() - render_started) * 1000.0
|
|
self.diagnostics.last_tick_at = timestamp
|
|
self._latest_snapshot = snapshot
|
|
|
|
changed_payloads: dict[str, bytes] = {}
|
|
packet_count = 0
|
|
output_snapshot: FrameSnapshot | None = None
|
|
preview_snapshot: FrameSnapshot | None = None
|
|
|
|
if output_due:
|
|
changed_payloads, changed_count, keepalive_count = self._select_output_payloads(
|
|
snapshot.controller_payloads,
|
|
timestamp,
|
|
)
|
|
output_snapshot = snapshot
|
|
self._last_output_at = timestamp
|
|
self._force_output = False
|
|
self.diagnostics.output_frames += 1
|
|
self.diagnostics.changed_controller_frames += changed_count
|
|
self.diagnostics.keepalive_controller_frames += keepalive_count
|
|
self.diagnostics.last_output_at = timestamp
|
|
if changed_payloads:
|
|
packet_count = self.ddp_client.count_packets(changed_payloads)
|
|
self.diagnostics.packets_built += packet_count
|
|
else:
|
|
self.diagnostics.stale_output_frames += 1
|
|
|
|
if preview_due:
|
|
preview_snapshot = snapshot
|
|
self._last_preview_at = timestamp
|
|
self._force_preview = False
|
|
self.diagnostics.preview_frames += 1
|
|
self.diagnostics.last_preview_at = timestamp
|
|
|
|
return TickResult(
|
|
timestamp=timestamp,
|
|
output_due=output_due,
|
|
preview_due=preview_due,
|
|
output_snapshot=output_snapshot,
|
|
preview_snapshot=preview_snapshot,
|
|
changed_payloads=changed_payloads,
|
|
packet_count=packet_count,
|
|
)
|
|
|
|
def send_due(self, now: float | None = None) -> TickResult:
|
|
result = self.tick(now=now)
|
|
if result.changed_payloads:
|
|
sent = self.ddp_client.send(result.changed_payloads)
|
|
self.diagnostics.packets_sent += sent
|
|
return result
|
|
|
|
def _interval(self, fps: float) -> float:
|
|
if fps <= 0:
|
|
return 0.0
|
|
return 1.0 / float(fps)
|
|
|
|
def _is_due(self, last_at: float | None, interval: float, now: float, forced: bool) -> bool:
|
|
if forced or last_at is None:
|
|
return True
|
|
if interval <= 0:
|
|
return True
|
|
return (now - last_at) >= max(0.0, interval - 1e-9)
|
|
|
|
def _select_output_payloads(self, payloads: dict[str, bytes], timestamp: float) -> tuple[dict[str, bytes], int, int]:
|
|
if not self.settings.delta_sending:
|
|
for host, payload in payloads.items():
|
|
self._last_sent_payloads[host] = payload
|
|
self._last_sent_at[host] = timestamp
|
|
return dict(payloads), len(payloads), 0
|
|
|
|
changed_payloads: dict[str, bytes] = {}
|
|
changed_count = 0
|
|
keepalive_count = 0
|
|
keepalive_seconds = max(0.0, float(self.settings.keepalive_seconds))
|
|
|
|
for host, payload in payloads.items():
|
|
previous = self._last_sent_payloads.get(host)
|
|
last_sent_at = self._last_sent_at.get(host)
|
|
unchanged = previous == payload
|
|
keepalive_due = (
|
|
keepalive_seconds > 0.0
|
|
and unchanged
|
|
and last_sent_at is not None
|
|
and (timestamp - last_sent_at) >= keepalive_seconds
|
|
)
|
|
if previous is None or not unchanged or keepalive_due:
|
|
changed_payloads[host] = payload
|
|
self._last_sent_payloads[host] = payload
|
|
self._last_sent_at[host] = timestamp
|
|
if keepalive_due and unchanged:
|
|
keepalive_count += 1
|
|
else:
|
|
changed_count += 1
|
|
return changed_payloads, changed_count, keepalive_count
|