325 lines
14 KiB
Python
325 lines
14 KiB
Python
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}
|