from __future__ import annotations from dataclasses import dataclass import time from .colors import scale from .models import ControllerSpec, FrameSnapshot, MappingSpec, RGB, SceneState from .patterns import render_pattern, utility_pattern @dataclass(slots=True) class RendererStats: tile_count: int controller_count: int total_leds: int class FastRenderer: def __init__(self, mapping: MappingSpec) -> None: self.mapping = mapping self.tiles = mapping.ordered_tiles() self.controllers: list[ControllerSpec] = mapping.build_controllers() self._tile_index = {tile.tile_id: index for index, tile in enumerate(self.tiles)} self._controller_buffers = {controller.host: bytearray(controller.payload_bytes) for controller in self.controllers} self._controller_zero = {host: bytes(len(buffer)) for host, buffer in self._controller_buffers.items()} self._gamma_table = self._build_gamma_table(mapping.global_gamma) def stats(self) -> RendererStats: return RendererStats( tile_count=len(self.tiles), controller_count=len(self.controllers), total_leds=sum(tile.led_total for tile in self.tiles), ) def render(self, scene: SceneState, timestamp: float | None = None) -> FrameSnapshot: now = time.perf_counter() if timestamp is None else float(timestamp) if scene.utility_mode != "none": tile_colors = utility_pattern(self.mapping, scene.utility_mode, scene.selected_tile_id, now) else: tile_colors = render_pattern(self.mapping, scene.pattern_id, scene.params, now, scene.tempo_bpm) controller_payloads = self._build_controller_payloads(tile_colors) return FrameSnapshot( timestamp=now, pattern_id=scene.pattern_id, tile_colors=tile_colors, controller_payloads=controller_payloads, ) def _build_controller_payloads(self, tile_colors: list[RGB]) -> dict[str, bytes]: for host, buffer in self._controller_buffers.items(): buffer[:] = self._controller_zero[host] for controller in self.controllers: payload = self._controller_buffers[controller.host] for route in controller.tiles: tile = self.tiles[self._tile_index[route.tile_id]] color = tile_colors[self._tile_index[route.tile_id]] color = self._apply_tile_brightness(color, tile.brightness) for byte_offset, led_count in route.segment_offsets: self._fill_segment(payload, byte_offset, led_count, color) return {host: bytes(buffer) for host, buffer in self._controller_buffers.items()} def _apply_tile_brightness(self, color: RGB, brightness: float) -> RGB: scaled = scale(color, brightness) return ( self._gamma_table[scaled[0]], self._gamma_table[scaled[1]], self._gamma_table[scaled[2]], ) def _build_gamma_table(self, gamma: float) -> list[int]: gamma = max(0.1, float(gamma)) table = [0] * 256 for value in range(256): normalized = value / 255.0 table[value] = max(0, min(255, int(round((normalized ** gamma) * 255.0)))) return table def _fill_segment(self, payload: bytearray, byte_offset: int, led_count: int, color: RGB) -> None: red, green, blue = color cursor = byte_offset for _ in range(max(0, led_count)): if cursor + 2 >= len(payload): break payload[cursor] = red payload[cursor + 1] = green payload[cursor + 2] = blue cursor += 3