from __future__ import annotations import math import time from typing import cast from app.config.models import InfinityMirrorConfig, SegmentConfig, TileConfig from app.core.colors import choose_pair, darken, label_contrast, sample_palette from app.core.geometry import segment_led_positions from app.core.pattern_compat import normalize_pattern_request from app.core.types import PatternParameters, PreviewFrame, RGBColor, TileFrame, TilePatternSample from app.patterns.base import PatternContext, PatternDescriptor, PatternRegistry from app.patterns.builtin import built_in_patterns class PatternEngine: def __init__(self) -> None: self.registry = PatternRegistry(built_in_patterns()) self._last_time = time.perf_counter() self._previous_samples: dict[str, TilePatternSample] = {} def descriptors(self) -> list[PatternDescriptor]: return self.registry.descriptors() def render_frame( self, config: InfinityMirrorConfig, pattern_id: str, params: PatternParameters, utility_mode: str = "none", selected_tile_id: str | None = None, timestamp: float | None = None, tempo_bpm: float = 60.0, tempo_phase: float | None = None, ) -> PreviewFrame: params = params.sanitized() pattern_id, normalized_params = normalize_pattern_request( pattern_id, params, rows=config.logical_display.rows, cols=config.logical_display.cols, ) if normalized_params is not None: params = normalized_params timestamp = time.perf_counter() if timestamp is None else timestamp if tempo_phase is None: tempo_phase = timestamp * max(0.05, float(tempo_bpm) / 60.0) delta = max(1.0 / 120.0, timestamp - self._last_time) self._last_time = timestamp if utility_mode != "none": temporal_profile = "direct" samples = self._render_utility_frame(config, params, utility_mode, selected_tile_id, timestamp) else: descriptor = self.registry.get(pattern_id).descriptor temporal_profile = descriptor.temporal_profile samples = self.registry.get(pattern_id).render( PatternContext( config=config, params=params, time_s=timestamp, tempo_bpm=tempo_bpm, tempo_phase=tempo_phase, ) ) blend_alpha = 1.0 - math.exp(-delta * (10.0 * (1.0 - params.fade) + 1.0)) frame_tiles: dict[str, TileFrame] = {} for tile in config.sorted_tiles(): sample = samples.get(tile.tile_id, self._fallback_sample(tile)) blended = sample if temporal_profile == "direct" else self._blend_sample(tile.tile_id, sample, blend_alpha) self._previous_samples[tile.tile_id] = blended brightness = params.brightness * tile.brightness_factor if tile.enabled else 0.04 fill = blended.fill_color.scaled(brightness) glow = blended.glow_color.scaled(brightness) rim = blended.rim_color.scaled(brightness) metadata = self._scale_metadata(blended.metadata, brightness) frame_tiles[tile.tile_id] = TileFrame( tile_id=tile.tile_id, row=tile.row, col=tile.col, fill_color=fill, glow_color=glow, rim_color=rim, label_color=label_contrast(fill), intensity=blended.intensity, enabled=tile.enabled, led_pixels=self._build_led_pixels( tile, fill, rim, timestamp, max(0.05, tempo_bpm / 60.0), config.defaults.tile_behavior, metadata, ), metadata=metadata, ) if params.color_mode == "palette": background_start = darken(sample_palette(params.palette, 0.78), 0.86) background_end = darken(sample_palette(params.palette, 0.22), 0.94) else: primary, secondary = choose_pair( params.color_mode, params.primary_color, params.secondary_color, params.palette, 0.22, ) background_start = darken(secondary.mix(primary, 0.18), 0.78) background_end = darken(primary.mix(secondary, 0.12), 0.92) return PreviewFrame( timestamp=timestamp, pattern_id=pattern_id, utility_mode=utility_mode, background_start=background_start, background_end=background_end, tiles=frame_tiles, ) def _blend_sample(self, tile_id: str, sample: TilePatternSample, blend_alpha: float) -> TilePatternSample: previous = self._previous_samples.get(tile_id, sample) return TilePatternSample( fill_color=previous.fill_color.mix(sample.fill_color, blend_alpha), glow_color=previous.glow_color.mix(sample.glow_color, blend_alpha), rim_color=previous.rim_color.mix(sample.rim_color, blend_alpha), label_color=previous.label_color.mix(sample.label_color, blend_alpha), intensity=previous.intensity + (sample.intensity - previous.intensity) * blend_alpha, metadata=sample.metadata, ) def _fallback_sample(self, tile: TileConfig) -> TilePatternSample: color = RGBColor(0.08, 0.12, 0.16) return TilePatternSample( fill_color=color, glow_color=color.mix(RGBColor.white(), 0.12), rim_color=color.mix(RGBColor.white(), 0.22), label_color=RGBColor.white(), intensity=0.2, ) def _scale_metadata(self, metadata: dict[str, object], brightness: float) -> dict[str, object]: if not metadata: return {} scaled = dict(metadata) diagonal_split = metadata.get("diagonal_split") if isinstance(diagonal_split, dict): color_a = diagonal_split.get("color_a") color_b = diagonal_split.get("color_b") scaled_a = color_a.scaled(brightness) if isinstance(color_a, RGBColor) else RGBColor.black() scaled_b = color_b.scaled(brightness) if isinstance(color_b, RGBColor) else RGBColor.black() scaled["diagonal_split"] = { **diagonal_split, "color_a": scaled_a, "color_b": scaled_b, } led_pixels = metadata.get("led_pixels") if isinstance(led_pixels, dict): scaled_led_pixels: dict[str, list[RGBColor]] = {} for segment_name, segment_colors in led_pixels.items(): if not isinstance(segment_name, str) or not isinstance(segment_colors, list): continue scaled_segment: list[RGBColor] = [] for color in segment_colors: if not isinstance(color, RGBColor): continue scaled_segment.append(color.scaled(brightness)) scaled_led_pixels[segment_name] = scaled_segment scaled["led_pixels"] = scaled_led_pixels return scaled def _build_led_pixels( self, tile: TileConfig, fill_color: RGBColor, rim_color: RGBColor, timestamp: float, tempo_hz: float, tile_behavior: str, metadata: dict[str, object], ) -> dict[str, list[RGBColor]]: led_pixels = metadata.get("led_pixels") if isinstance(led_pixels, dict): return self._build_metadata_led_pixels(tile, cast(dict[str, list[RGBColor]], led_pixels)) diagonal_split = metadata.get("diagonal_split") if isinstance(diagonal_split, dict): return self._build_diagonal_split_pixels(tile, diagonal_split) pixels: dict[str, list[RGBColor]] = {} if tile_behavior == "solid_color_per_tile": for segment in tile.segments: pixels[segment.name] = [fill_color for _ in range(segment.led_count)] return pixels for segment in tile.segments: segment_pixels: list[RGBColor] = [] for index in range(segment.led_count): pulse = 0.04 * math.sin((timestamp * tempo_hz * 3.0) + index * 0.15 + tile.row * 0.9 + tile.col * 0.55) amount = 0.05 + max(0.0, pulse) segment_pixels.append(fill_color.mix(rim_color, amount)) if segment.reverse: segment_pixels.reverse() pixels[segment.name] = segment_pixels return pixels def _build_metadata_led_pixels(self, tile: TileConfig, led_pixels: dict[str, list[RGBColor]]) -> dict[str, list[RGBColor]]: pixels: dict[str, list[RGBColor]] = {} for segment in tile.segments: colors = list(led_pixels.get(segment.name, [])) if len(colors) < segment.led_count: colors.extend([RGBColor.black()] * (segment.led_count - len(colors))) pixels[segment.name] = colors[: segment.led_count] return pixels def _build_diagonal_split_pixels(self, tile: TileConfig, diagonal_split: dict[str, object]) -> dict[str, list[RGBColor]]: orientation = str(diagonal_split.get("orientation", "slash")) color_a = diagonal_split.get("color_a") color_b = diagonal_split.get("color_b") if not isinstance(color_a, RGBColor) or not isinstance(color_b, RGBColor): return {} pixels: dict[str, list[RGBColor]] = {} for segment in tile.segments: segment_pixels: list[RGBColor] = [] for x_pos, y_pos in segment_led_positions(tile, segment): if orientation == "backslash": color = color_a if y_pos <= x_pos else color_b else: color = color_a if y_pos <= 1.0 - x_pos else color_b segment_pixels.append(color) pixels[segment.name] = segment_pixels return pixels def _render_utility_frame( self, config: InfinityMirrorConfig, params: PatternParameters, utility_mode: str, selected_tile_id: str | None, timestamp: float, ) -> dict[str, TilePatternSample]: tiles = config.sorted_tiles() blank = TilePatternSample( fill_color=RGBColor.black(), glow_color=RGBColor(0.04, 0.06, 0.07), rim_color=RGBColor(0.08, 0.1, 0.12), label_color=RGBColor.white(), intensity=0.1, ) if utility_mode == "blackout": return {tile.tile_id: blank for tile in tiles} if utility_mode == "identify": active_index = int(timestamp * 2.0) % max(1, len(tiles)) result = {tile.tile_id: blank for tile in tiles} active = tiles[active_index] result[active.tile_id] = TilePatternSample( fill_color=RGBColor.white().scaled(0.9), glow_color=RGBColor(1.0, 0.85, 0.4), rim_color=RGBColor.white(), label_color=RGBColor.black(), intensity=1.0, metadata={"active": True}, ) return result if utility_mode == "single_tile": result = {tile.tile_id: blank for tile in tiles} if selected_tile_id and selected_tile_id in result: result[selected_tile_id] = TilePatternSample( fill_color=RGBColor.white(), glow_color=RGBColor(0.9, 0.96, 1.0), rim_color=RGBColor.white(), label_color=RGBColor.black(), intensity=1.0, metadata={"active": True}, ) return result if utility_mode == "row_test": palette = [sample_palette(params.palette, row / max(1, config.logical_display.rows - 1)) for row in range(config.logical_display.rows)] return { tile.tile_id: TilePatternSample( fill_color=palette[tile.row - 1].scaled(0.95), glow_color=palette[tile.row - 1].mix(RGBColor.white(), 0.2), rim_color=palette[tile.row - 1].mix(RGBColor.white(), 0.3), label_color=RGBColor.black() if tile.row == 1 else RGBColor.white(), intensity=0.9, ) for tile in tiles } if utility_mode == "column_test": palette = [sample_palette(params.palette, col / max(1, config.logical_display.cols - 1)) for col in range(config.logical_display.cols)] return { tile.tile_id: TilePatternSample( fill_color=palette[tile.col - 1].scaled(0.95), glow_color=palette[tile.col - 1].mix(RGBColor.white(), 0.2), rim_color=palette[tile.col - 1].mix(RGBColor.white(), 0.3), label_color=RGBColor.white(), intensity=0.9, ) for tile in tiles } if utility_mode == "checker_test": return { tile.tile_id: TilePatternSample( fill_color=(RGBColor.white() if (tile.row + tile.col) % 2 == 0 else RGBColor(0.05, 0.08, 0.1)), glow_color=RGBColor(0.8, 0.9, 1.0) if (tile.row + tile.col) % 2 == 0 else RGBColor(0.08, 0.1, 0.14), rim_color=RGBColor.white() if (tile.row + tile.col) % 2 == 0 else RGBColor(0.12, 0.16, 0.18), label_color=RGBColor.black() if (tile.row + tile.col) % 2 == 0 else RGBColor.white(), intensity=1.0 if (tile.row + tile.col) % 2 == 0 else 0.15, ) for tile in tiles } return {tile.tile_id: blank for tile in tiles}