from __future__ import annotations from dataclasses import dataclass, field from datetime import UTC, datetime def clamp_channel(value: int) -> int: return max(0, min(255, int(value))) @dataclass(slots=True) class FrameLayer: name: str priority: int values_by_universe: dict[int, dict[int, int]] precedence_map_by_universe: dict[int, dict[int, str]] = field(default_factory=dict) created_at: datetime = field(default_factory=lambda: datetime.now(UTC)) @property def values(self) -> dict[int, int]: return self.values_by_universe.get(1, {}) @property def precedence_map(self) -> dict[int, str]: return self.precedence_map_by_universe.get(1, {}) @classmethod def from_channel_values( cls, name: str, priority: int, values: dict[int, int], precedence_map: dict[int, str] | None = None, *, universe: int = 1, ) -> "FrameLayer": return cls( name=name, priority=priority, values_by_universe={universe: values}, precedence_map_by_universe={universe: precedence_map or {}}, ) @dataclass(slots=True) class DmxFrame: universe: int = 1 values: list[int] = field(default_factory=lambda: [0] * 512) source_map: list[str] = field(default_factory=lambda: ["idle"] * 512) def set_channel(self, channel: int, value: int, source: str = "manual") -> None: if not 1 <= channel <= 512: raise ValueError("Channel must be in range 1..512") self.values[channel - 1] = clamp_channel(value) self.source_map[channel - 1] = source def copy(self) -> DmxFrame: return DmxFrame(self.universe, self.values.copy(), self.source_map.copy()) def merge_layers(layers: list[FrameLayer], blackout: bool = False) -> dict[int, DmxFrame]: universes = sorted( { int(universe) for layer in layers for universe in layer.values_by_universe } ) or [1] if blackout: return {universe: DmxFrame(universe=universe) for universe in universes} ordered = sorted(layers, key=lambda layer: (layer.priority, layer.created_at)) frames = {universe: DmxFrame(universe=universe) for universe in universes} for universe, frame in frames.items(): for channel in range(1, 513): channel_candidates: list[tuple[FrameLayer, int]] = [] for layer in ordered: values = layer.values_by_universe.get(universe, {}) if channel in values: channel_candidates.append((layer, clamp_channel(values[channel]))) if not channel_candidates: continue precedence_map = channel_candidates[-1][0].precedence_map_by_universe.get(universe, {}) precedence = precedence_map.get(channel, "ltp").lower() if precedence == "htp": chosen_layer, chosen_value = max(channel_candidates, key=lambda pair: pair[1]) else: chosen_layer, chosen_value = channel_candidates[-1] frame.set_channel(channel, chosen_value, chosen_layer.name) return frames