First upload, 18 controller version
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
"""Infinity Mirror control app."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,2 @@
|
||||
"""Configuration models and XML helpers."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,93 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config.models import InfinityMirrorConfig, TileConfig
|
||||
from app.network.wled import DiscoveredWledDevice, normalize_mac_address
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class TileAssignmentSnapshot:
|
||||
tile_id: str
|
||||
controller_ip: str = ""
|
||||
controller_host: str = ""
|
||||
controller_name: str = ""
|
||||
controller_mac: str = ""
|
||||
|
||||
@property
|
||||
def is_assigned(self) -> bool:
|
||||
return any((self.controller_ip, self.controller_host, self.controller_name, self.controller_mac))
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DeviceAssignmentResult:
|
||||
target_tile_id: str
|
||||
previous_tile_id: str | None = None
|
||||
displaced_tile_assignment: TileAssignmentSnapshot | None = None
|
||||
|
||||
|
||||
def capture_tile_assignment(tile: TileConfig) -> TileAssignmentSnapshot:
|
||||
return TileAssignmentSnapshot(
|
||||
tile_id=tile.tile_id,
|
||||
controller_ip=tile.controller_ip.strip(),
|
||||
controller_host=tile.controller_host.strip(),
|
||||
controller_name=tile.controller_name.strip(),
|
||||
controller_mac=normalize_mac_address(tile.controller_mac),
|
||||
)
|
||||
|
||||
|
||||
def clear_tile_assignment(tile: TileConfig) -> None:
|
||||
tile.controller_ip = ""
|
||||
tile.controller_host = ""
|
||||
tile.controller_name = ""
|
||||
tile.controller_mac = ""
|
||||
|
||||
|
||||
def tile_matches_device(tile: TileConfig, device: DiscoveredWledDevice) -> bool:
|
||||
tile_mac = normalize_mac_address(tile.controller_mac)
|
||||
device_mac = normalize_mac_address(device.mac_address)
|
||||
if tile_mac and device_mac:
|
||||
return tile_mac == device_mac
|
||||
return bool(tile.controller_ip.strip() and tile.controller_ip.strip() == device.ip_address)
|
||||
|
||||
|
||||
def find_tile_for_device(config: InfinityMirrorConfig, device: DiscoveredWledDevice) -> TileConfig | None:
|
||||
for tile in config.sorted_tiles():
|
||||
if tile_matches_device(tile, device):
|
||||
return tile
|
||||
return None
|
||||
|
||||
|
||||
def assign_device_to_tile(
|
||||
config: InfinityMirrorConfig,
|
||||
device: DiscoveredWledDevice,
|
||||
target_tile_id: str,
|
||||
) -> DeviceAssignmentResult:
|
||||
tile_lookup = config.tile_lookup()
|
||||
target_tile = tile_lookup.get(target_tile_id)
|
||||
if target_tile is None:
|
||||
raise KeyError(f"Unknown tile id: {target_tile_id}")
|
||||
|
||||
previous_tile = find_tile_for_device(config, device)
|
||||
displaced_assignment = capture_tile_assignment(target_tile)
|
||||
displaced_snapshot = displaced_assignment if displaced_assignment.is_assigned else None
|
||||
|
||||
if previous_tile is not None and previous_tile.tile_id != target_tile.tile_id:
|
||||
clear_tile_assignment(previous_tile)
|
||||
|
||||
if displaced_snapshot is not None:
|
||||
target_mac = normalize_mac_address(target_tile.controller_mac)
|
||||
if previous_tile is None or previous_tile.tile_id != target_tile.tile_id:
|
||||
if not (target_mac and target_mac == device.mac_address) and target_tile.controller_ip.strip() != device.ip_address:
|
||||
clear_tile_assignment(target_tile)
|
||||
|
||||
target_tile.controller_ip = device.ip_address
|
||||
target_tile.controller_host = device.hostname
|
||||
target_tile.controller_name = device.instance_name
|
||||
target_tile.controller_mac = device.mac_address
|
||||
|
||||
return DeviceAssignmentResult(
|
||||
target_tile_id=target_tile.tile_id,
|
||||
previous_tile_id=previous_tile.tile_id if previous_tile is not None and previous_tile.tile_id != target_tile.tile_id else None,
|
||||
displaced_tile_assignment=displaced_snapshot,
|
||||
)
|
||||
@@ -0,0 +1,121 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from pathlib import Path
|
||||
from typing import Iterable
|
||||
import copy
|
||||
|
||||
|
||||
@dataclass
|
||||
class CompositionInfo:
|
||||
width: int = 1200
|
||||
height: int = 600
|
||||
|
||||
|
||||
@dataclass
|
||||
class SourceInfo:
|
||||
original_export: str = ""
|
||||
derived_from: str = ""
|
||||
composition: CompositionInfo = field(default_factory=CompositionInfo)
|
||||
|
||||
|
||||
@dataclass
|
||||
class LogicalDisplayConfig:
|
||||
rows: int = 3
|
||||
cols: int = 6
|
||||
preview_width: int = 1200
|
||||
preview_height: int = 600
|
||||
tile_width: int = 200
|
||||
tile_height: int = 200
|
||||
|
||||
|
||||
@dataclass
|
||||
class DefaultsConfig:
|
||||
protocol: str = "artnet"
|
||||
subnet: int = 0
|
||||
color_format: str = "rgb"
|
||||
tile_behavior: str = "solid_color_per_tile"
|
||||
global_gamma: float = 2.2
|
||||
|
||||
|
||||
@dataclass
|
||||
class CalibrationConfig:
|
||||
brightness: float = 1.0
|
||||
red_gain: float = 1.0
|
||||
green_gain: float = 1.0
|
||||
blue_gain: float = 1.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class SegmentConfig:
|
||||
name: str
|
||||
side: str
|
||||
start_channel: int
|
||||
led_count: int
|
||||
orientation_rad: float = 0.0
|
||||
x0: float = 0.0
|
||||
y0: float = 0.0
|
||||
x1: float = 0.0
|
||||
y1: float = 0.0
|
||||
reverse: bool = False
|
||||
|
||||
|
||||
@dataclass
|
||||
class TileConfig:
|
||||
tile_id: str
|
||||
row: int
|
||||
col: int
|
||||
screen_name: str = ""
|
||||
controller_ip: str = ""
|
||||
controller_host: str = ""
|
||||
controller_name: str = ""
|
||||
controller_mac: str = ""
|
||||
universe: int = 0
|
||||
subnet: int = 0
|
||||
led_total: int = 0
|
||||
x0: float = 0.0
|
||||
y0: float = 0.0
|
||||
x1: float = 0.0
|
||||
y1: float = 0.0
|
||||
enabled: bool = True
|
||||
calibration: CalibrationConfig = field(default_factory=CalibrationConfig)
|
||||
segments: list[SegmentConfig] = field(default_factory=list)
|
||||
|
||||
@property
|
||||
def brightness_factor(self) -> float:
|
||||
return self.calibration.brightness
|
||||
|
||||
@brightness_factor.setter
|
||||
def brightness_factor(self, value: float) -> None:
|
||||
self.calibration.brightness = value
|
||||
|
||||
|
||||
@dataclass
|
||||
class InfinityMirrorConfig:
|
||||
name: str = "Infinity Mirror"
|
||||
version: str = "1.0"
|
||||
source: SourceInfo = field(default_factory=SourceInfo)
|
||||
logical_display: LogicalDisplayConfig = field(default_factory=LogicalDisplayConfig)
|
||||
defaults: DefaultsConfig = field(default_factory=DefaultsConfig)
|
||||
tiles: list[TileConfig] = field(default_factory=list)
|
||||
file_path: Path | None = None
|
||||
|
||||
def clone(self) -> "InfinityMirrorConfig":
|
||||
return copy.deepcopy(self)
|
||||
|
||||
def sorted_tiles(self) -> list[TileConfig]:
|
||||
return sorted(self.tiles, key=lambda tile: (tile.row, tile.col, tile.tile_id))
|
||||
|
||||
def tile_lookup(self) -> dict[str, TileConfig]:
|
||||
return {tile.tile_id: tile for tile in self.tiles}
|
||||
|
||||
def tile_at(self, row: int, col: int) -> TileConfig | None:
|
||||
for tile in self.tiles:
|
||||
if tile.row == row and tile.col == col:
|
||||
return tile
|
||||
return None
|
||||
|
||||
def all_segments(self) -> Iterable[tuple[TileConfig, SegmentConfig]]:
|
||||
for tile in self.sorted_tiles():
|
||||
for segment in tile.segments:
|
||||
yield tile, segment
|
||||
@@ -0,0 +1,361 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
import ipaddress
|
||||
import xml.etree.ElementTree as ET
|
||||
|
||||
from .models import (
|
||||
CalibrationConfig,
|
||||
CompositionInfo,
|
||||
DefaultsConfig,
|
||||
InfinityMirrorConfig,
|
||||
LogicalDisplayConfig,
|
||||
SegmentConfig,
|
||||
SourceInfo,
|
||||
TileConfig,
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ValidationResult:
|
||||
errors: list[str]
|
||||
|
||||
@property
|
||||
def is_valid(self) -> bool:
|
||||
return not self.errors
|
||||
|
||||
|
||||
class MappingValidationError(ValueError):
|
||||
def __init__(self, errors: list[str]) -> None:
|
||||
self.errors = errors
|
||||
super().__init__("\n".join(errors))
|
||||
|
||||
|
||||
def _get_required(node: ET.Element, key: str) -> str:
|
||||
value = node.get(key)
|
||||
if value is None:
|
||||
raise MappingValidationError([f"Missing required attribute '{key}' on <{node.tag}>."])
|
||||
return value
|
||||
|
||||
|
||||
def _get_int(node: ET.Element, key: str, default: int | None = None) -> int:
|
||||
raw = node.get(key)
|
||||
if raw is None:
|
||||
if default is None:
|
||||
raise MappingValidationError([f"Missing required integer attribute '{key}' on <{node.tag}>."])
|
||||
return default
|
||||
try:
|
||||
return int(raw)
|
||||
except ValueError as exc:
|
||||
raise MappingValidationError([f"Invalid integer for '{key}' on <{node.tag}>: {raw!r}"]) from exc
|
||||
|
||||
|
||||
def _get_float(node: ET.Element, key: str, default: float | None = None) -> float:
|
||||
raw = node.get(key)
|
||||
if raw is None:
|
||||
if default is None:
|
||||
raise MappingValidationError([f"Missing required float attribute '{key}' on <{node.tag}>."])
|
||||
return default
|
||||
try:
|
||||
return float(raw)
|
||||
except ValueError as exc:
|
||||
raise MappingValidationError([f"Invalid float for '{key}' on <{node.tag}>: {raw!r}"]) from exc
|
||||
|
||||
|
||||
def _get_bool(node: ET.Element, key: str, default: bool = False) -> bool:
|
||||
raw = node.get(key)
|
||||
if raw is None:
|
||||
return default
|
||||
return raw.strip().lower() in {"1", "true", "yes", "on"}
|
||||
|
||||
|
||||
def load_config(path: str | Path, validate: bool = True) -> InfinityMirrorConfig:
|
||||
xml_text = Path(path).read_text(encoding="utf-8")
|
||||
config = load_config_from_string(xml_text, validate=validate)
|
||||
config.file_path = Path(path)
|
||||
return config
|
||||
|
||||
|
||||
def load_config_from_string(xml_text: str, validate: bool = True) -> InfinityMirrorConfig:
|
||||
try:
|
||||
root = ET.fromstring(xml_text)
|
||||
except ET.ParseError as exc:
|
||||
raise MappingValidationError([f"XML parse error: {exc}"]) from exc
|
||||
|
||||
if root.tag != "InfinityMirrorConfig":
|
||||
raise MappingValidationError([f"Unexpected root element: <{root.tag}>"])
|
||||
|
||||
source_node = root.find("Source")
|
||||
composition_node = source_node.find("OriginalComposition") if source_node is not None else None
|
||||
source = SourceInfo(
|
||||
original_export=source_node.findtext("OriginalExport", default="") if source_node is not None else "",
|
||||
derived_from=source_node.findtext("DerivedFrom", default="") if source_node is not None else "",
|
||||
composition=CompositionInfo(
|
||||
width=int(composition_node.get("width", "1200")) if composition_node is not None else 1200,
|
||||
height=int(composition_node.get("height", "600")) if composition_node is not None else 600,
|
||||
),
|
||||
)
|
||||
|
||||
logical_node = root.find("LogicalDisplay")
|
||||
logical_display = LogicalDisplayConfig(
|
||||
rows=_get_int(logical_node, "rows", 3) if logical_node is not None else 3,
|
||||
cols=_get_int(logical_node, "cols", 6) if logical_node is not None else 6,
|
||||
preview_width=_get_int(logical_node, "previewWidth", 1200) if logical_node is not None else 1200,
|
||||
preview_height=_get_int(logical_node, "previewHeight", 600) if logical_node is not None else 600,
|
||||
tile_width=_get_int(logical_node, "tileWidth", 200) if logical_node is not None else 200,
|
||||
tile_height=_get_int(logical_node, "tileHeight", 200) if logical_node is not None else 200,
|
||||
)
|
||||
|
||||
defaults_node = root.find("Defaults")
|
||||
defaults = DefaultsConfig(
|
||||
protocol=defaults_node.findtext("protocol", default="artnet") if defaults_node is not None else "artnet",
|
||||
subnet=int(defaults_node.findtext("subnet", default="0")) if defaults_node is not None else 0,
|
||||
color_format=defaults_node.findtext("colorFormat", default="rgb") if defaults_node is not None else "rgb",
|
||||
tile_behavior=defaults_node.findtext("tileBehavior", default="solid_color_per_tile")
|
||||
if defaults_node is not None
|
||||
else "solid_color_per_tile",
|
||||
global_gamma=float(defaults_node.findtext("globalGamma", default="2.2")) if defaults_node is not None else 2.2,
|
||||
)
|
||||
|
||||
tiles: list[TileConfig] = []
|
||||
tiles_node = root.find("Tiles")
|
||||
if tiles_node is None:
|
||||
raise MappingValidationError(["Missing <Tiles> element."])
|
||||
|
||||
for tile_node in tiles_node.findall("Tile"):
|
||||
calibration_node = tile_node.find("Calibration")
|
||||
calibration = CalibrationConfig(
|
||||
brightness=_get_float(calibration_node, "brightness", 1.0) if calibration_node is not None else 1.0,
|
||||
red_gain=_get_float(calibration_node, "redGain", 1.0) if calibration_node is not None else 1.0,
|
||||
green_gain=_get_float(calibration_node, "greenGain", 1.0) if calibration_node is not None else 1.0,
|
||||
blue_gain=_get_float(calibration_node, "blueGain", 1.0) if calibration_node is not None else 1.0,
|
||||
)
|
||||
|
||||
segments: list[SegmentConfig] = []
|
||||
segments_node = tile_node.find("Segments")
|
||||
for segment_node in segments_node.findall("Segment") if segments_node is not None else []:
|
||||
segments.append(
|
||||
SegmentConfig(
|
||||
name=segment_node.get("name", ""),
|
||||
side=segment_node.get("side", ""),
|
||||
start_channel=_get_int(segment_node, "startChannel", 1),
|
||||
led_count=_get_int(segment_node, "ledCount", 0),
|
||||
orientation_rad=_get_float(segment_node, "orientationRad", 0.0),
|
||||
x0=_get_float(segment_node, "x0", 0.0),
|
||||
y0=_get_float(segment_node, "y0", 0.0),
|
||||
x1=_get_float(segment_node, "x1", 0.0),
|
||||
y1=_get_float(segment_node, "y1", 0.0),
|
||||
reverse=_get_bool(segment_node, "reverse", False),
|
||||
)
|
||||
)
|
||||
segments.sort(key=lambda segment: (segment.start_channel, segment.name))
|
||||
|
||||
tiles.append(
|
||||
TileConfig(
|
||||
tile_id=_get_required(tile_node, "id"),
|
||||
row=_get_int(tile_node, "row"),
|
||||
col=_get_int(tile_node, "col"),
|
||||
screen_name=tile_node.get("screenName", ""),
|
||||
controller_ip=tile_node.get("ip", ""),
|
||||
controller_host=tile_node.get("controllerHost", ""),
|
||||
controller_name=tile_node.get("controllerName", ""),
|
||||
controller_mac=tile_node.get("controllerMac", ""),
|
||||
universe=_get_int(tile_node, "universe", 0),
|
||||
subnet=_get_int(tile_node, "subnet", defaults.subnet),
|
||||
led_total=_get_int(tile_node, "ledTotal", 0),
|
||||
x0=_get_float(tile_node, "x0", 0.0),
|
||||
y0=_get_float(tile_node, "y0", 0.0),
|
||||
x1=_get_float(tile_node, "x1", 0.0),
|
||||
y1=_get_float(tile_node, "y1", 0.0),
|
||||
enabled=_get_bool(tile_node, "enabled", True),
|
||||
calibration=calibration,
|
||||
segments=segments,
|
||||
)
|
||||
)
|
||||
|
||||
config = InfinityMirrorConfig(
|
||||
name=root.get("name", "Infinity Mirror"),
|
||||
version=root.get("version", "1.0"),
|
||||
source=source,
|
||||
logical_display=logical_display,
|
||||
defaults=defaults,
|
||||
tiles=tiles,
|
||||
)
|
||||
|
||||
if validate:
|
||||
result = validate_config(config)
|
||||
if not result.is_valid:
|
||||
raise MappingValidationError(result.errors)
|
||||
return config
|
||||
|
||||
|
||||
def validate_config(config: InfinityMirrorConfig) -> ValidationResult:
|
||||
errors: list[str] = []
|
||||
rows = config.logical_display.rows
|
||||
cols = config.logical_display.cols
|
||||
|
||||
if rows <= 0 or cols <= 0:
|
||||
errors.append("Logical display must have positive rows and columns.")
|
||||
|
||||
seen_ids: set[str] = set()
|
||||
seen_positions: set[tuple[int, int]] = set()
|
||||
|
||||
for tile in config.tiles:
|
||||
if not tile.tile_id:
|
||||
errors.append("Tile id cannot be empty.")
|
||||
elif tile.tile_id in seen_ids:
|
||||
errors.append(f"Duplicate tile id: {tile.tile_id}")
|
||||
seen_ids.add(tile.tile_id)
|
||||
|
||||
if not (1 <= tile.row <= rows):
|
||||
errors.append(f"{tile.tile_id}: row {tile.row} is outside 1..{rows}.")
|
||||
if not (1 <= tile.col <= cols):
|
||||
errors.append(f"{tile.tile_id}: col {tile.col} is outside 1..{cols}.")
|
||||
|
||||
position = (tile.row, tile.col)
|
||||
if position in seen_positions:
|
||||
errors.append(f"Duplicate tile position row={tile.row}, col={tile.col}.")
|
||||
seen_positions.add(position)
|
||||
|
||||
if tile.controller_ip:
|
||||
try:
|
||||
ipaddress.ip_address(tile.controller_ip)
|
||||
except ValueError:
|
||||
errors.append(f"{tile.tile_id}: invalid IP address {tile.controller_ip!r}.")
|
||||
|
||||
if tile.universe < 0:
|
||||
errors.append(f"{tile.tile_id}: universe must be >= 0.")
|
||||
if not (0 <= tile.subnet <= 15):
|
||||
errors.append(f"{tile.tile_id}: subnet must be between 0 and 15.")
|
||||
if tile.led_total < 0:
|
||||
errors.append(f"{tile.tile_id}: led count must be >= 0.")
|
||||
if tile.brightness_factor < 0:
|
||||
errors.append(f"{tile.tile_id}: brightness factor must be >= 0.")
|
||||
|
||||
segment_led_total = 0
|
||||
for segment in tile.segments:
|
||||
if not segment.name:
|
||||
errors.append(f"{tile.tile_id}: segment name cannot be empty.")
|
||||
if segment.led_count <= 0:
|
||||
errors.append(f"{tile.tile_id}/{segment.name}: led count must be > 0.")
|
||||
if segment.start_channel <= 0:
|
||||
errors.append(f"{tile.tile_id}/{segment.name}: start channel must be > 0.")
|
||||
segment_led_total += segment.led_count
|
||||
|
||||
if tile.segments and segment_led_total != tile.led_total:
|
||||
errors.append(
|
||||
f"{tile.tile_id}: ledTotal={tile.led_total} does not match segment sum {segment_led_total}."
|
||||
)
|
||||
|
||||
expected_tiles = rows * cols
|
||||
if len(config.tiles) != expected_tiles:
|
||||
errors.append(f"Expected {expected_tiles} tiles for a {rows}x{cols} display, found {len(config.tiles)}.")
|
||||
|
||||
return ValidationResult(errors)
|
||||
|
||||
|
||||
def config_to_xml_string(config: InfinityMirrorConfig) -> str:
|
||||
root = ET.Element("InfinityMirrorConfig", {"name": config.name, "version": config.version})
|
||||
|
||||
source_node = ET.SubElement(root, "Source")
|
||||
ET.SubElement(source_node, "OriginalExport").text = config.source.original_export
|
||||
ET.SubElement(source_node, "DerivedFrom").text = config.source.derived_from
|
||||
ET.SubElement(
|
||||
source_node,
|
||||
"OriginalComposition",
|
||||
{
|
||||
"width": str(config.source.composition.width),
|
||||
"height": str(config.source.composition.height),
|
||||
},
|
||||
)
|
||||
|
||||
ET.SubElement(
|
||||
root,
|
||||
"LogicalDisplay",
|
||||
{
|
||||
"rows": str(config.logical_display.rows),
|
||||
"cols": str(config.logical_display.cols),
|
||||
"previewWidth": str(config.logical_display.preview_width),
|
||||
"previewHeight": str(config.logical_display.preview_height),
|
||||
"tileWidth": str(config.logical_display.tile_width),
|
||||
"tileHeight": str(config.logical_display.tile_height),
|
||||
},
|
||||
)
|
||||
|
||||
defaults_node = ET.SubElement(root, "Defaults")
|
||||
ET.SubElement(defaults_node, "protocol").text = config.defaults.protocol
|
||||
ET.SubElement(defaults_node, "subnet").text = str(config.defaults.subnet)
|
||||
ET.SubElement(defaults_node, "colorFormat").text = config.defaults.color_format
|
||||
ET.SubElement(defaults_node, "tileBehavior").text = config.defaults.tile_behavior
|
||||
ET.SubElement(defaults_node, "globalGamma").text = str(config.defaults.global_gamma)
|
||||
|
||||
tiles_node = ET.SubElement(root, "Tiles")
|
||||
for tile in config.sorted_tiles():
|
||||
tile_attributes = {
|
||||
"id": tile.tile_id,
|
||||
"row": str(tile.row),
|
||||
"col": str(tile.col),
|
||||
"screenName": tile.screen_name,
|
||||
"ip": tile.controller_ip,
|
||||
"universe": str(tile.universe),
|
||||
"subnet": str(tile.subnet),
|
||||
"ledTotal": str(tile.led_total),
|
||||
"x0": _format_float(tile.x0),
|
||||
"y0": _format_float(tile.y0),
|
||||
"x1": _format_float(tile.x1),
|
||||
"y1": _format_float(tile.y1),
|
||||
"enabled": "true" if tile.enabled else "false",
|
||||
}
|
||||
if tile.controller_host:
|
||||
tile_attributes["controllerHost"] = tile.controller_host
|
||||
if tile.controller_name:
|
||||
tile_attributes["controllerName"] = tile.controller_name
|
||||
if tile.controller_mac:
|
||||
tile_attributes["controllerMac"] = tile.controller_mac
|
||||
|
||||
tile_node = ET.SubElement(tiles_node, "Tile", tile_attributes)
|
||||
ET.SubElement(
|
||||
tile_node,
|
||||
"Calibration",
|
||||
{
|
||||
"brightness": _format_float(tile.calibration.brightness),
|
||||
"redGain": _format_float(tile.calibration.red_gain),
|
||||
"greenGain": _format_float(tile.calibration.green_gain),
|
||||
"blueGain": _format_float(tile.calibration.blue_gain),
|
||||
},
|
||||
)
|
||||
segments_node = ET.SubElement(tile_node, "Segments")
|
||||
for segment in sorted(tile.segments, key=lambda item: (item.start_channel, item.name)):
|
||||
ET.SubElement(
|
||||
segments_node,
|
||||
"Segment",
|
||||
{
|
||||
"name": segment.name,
|
||||
"side": segment.side,
|
||||
"startChannel": str(segment.start_channel),
|
||||
"ledCount": str(segment.led_count),
|
||||
"orientationRad": _format_float(segment.orientation_rad),
|
||||
"x0": _format_float(segment.x0),
|
||||
"y0": _format_float(segment.y0),
|
||||
"x1": _format_float(segment.x1),
|
||||
"y1": _format_float(segment.y1),
|
||||
"reverse": "true" if segment.reverse else "false",
|
||||
},
|
||||
)
|
||||
|
||||
ET.indent(root, space=" ")
|
||||
return ET.tostring(root, encoding="unicode", xml_declaration=True)
|
||||
|
||||
|
||||
def save_config(config: InfinityMirrorConfig, path: str | Path) -> None:
|
||||
result = validate_config(config)
|
||||
if not result.is_valid:
|
||||
raise MappingValidationError(result.errors)
|
||||
Path(path).write_text(config_to_xml_string(config), encoding="utf-8")
|
||||
config.file_path = Path(path)
|
||||
|
||||
|
||||
def _format_float(value: float) -> str:
|
||||
text = f"{value:.6f}".rstrip("0").rstrip(".")
|
||||
return text if text else "0"
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Pure Python core types and orchestration helpers."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,162 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import colorsys
|
||||
import math
|
||||
|
||||
from .types import RGBColor, clamp
|
||||
|
||||
|
||||
PALETTES: dict[str, list[str]] = {
|
||||
"Laser Club": ["#00F0FF", "#008CFF", "#6A00FF", "#060814"],
|
||||
"Magenta Drive": ["#FF006E", "#FF4DA6", "#7A00FF", "#120318"],
|
||||
"Warehouse Heat": ["#FF5A1F", "#FF9E00", "#FFD000", "#140600"],
|
||||
"UV Riot": ["#7A00FF", "#B100FF", "#FF00A8", "#100014"],
|
||||
"Redline": ["#FF2D55", "#FF6A00", "#FFB000", "#160406"],
|
||||
"Sodium Haze": ["#FF7A00", "#FFB000", "#FFD86B", "#120700"],
|
||||
"Afterhours": ["#F72585", "#B5179E", "#7209B7", "#14031A"],
|
||||
"Voltage": ["#00E5FF", "#00B3FF", "#3A86FF", "#050A14"],
|
||||
}
|
||||
|
||||
PALETTE_ALIASES: dict[str, str] = {
|
||||
"Aurora": "Laser Club",
|
||||
"Cinder": "Warehouse Heat",
|
||||
"Sapphire": "Voltage",
|
||||
"Deep Blue": "Voltage",
|
||||
"Neon Tide": "Laser Club",
|
||||
"Sunset Drive": "Redline",
|
||||
"Moss Signal": "Sodium Haze",
|
||||
"Polar": "Voltage",
|
||||
"Signal Warm": "Warehouse Heat",
|
||||
"Steel Bloom": "Magenta Drive",
|
||||
}
|
||||
|
||||
DEFAULT_PALETTE = "Laser Club"
|
||||
|
||||
RANDOM_EFFECT_COLORS: tuple[RGBColor, ...] = (
|
||||
RGBColor(1.0, 0.12, 0.12),
|
||||
RGBColor(1.0, 0.42, 0.0),
|
||||
RGBColor(1.0, 0.74, 0.0),
|
||||
RGBColor(0.7, 1.0, 0.0),
|
||||
RGBColor(0.0, 1.0, 0.3),
|
||||
RGBColor(0.0, 0.86, 1.0),
|
||||
RGBColor(0.0, 0.32, 1.0),
|
||||
RGBColor(0.7, 0.0, 1.0),
|
||||
)
|
||||
|
||||
|
||||
def hex_to_color(value: str) -> RGBColor:
|
||||
text = value.strip().lstrip("#")
|
||||
if len(text) != 6:
|
||||
return RGBColor.white()
|
||||
try:
|
||||
return RGBColor(*(int(text[index : index + 2], 16) / 255.0 for index in (0, 2, 4)))
|
||||
except ValueError:
|
||||
return RGBColor.white()
|
||||
|
||||
|
||||
def canonical_palette_name(name: str) -> str:
|
||||
candidate = PALETTE_ALIASES.get(name, name)
|
||||
return candidate if candidate in PALETTES else DEFAULT_PALETTE
|
||||
|
||||
|
||||
def palette_colors(name: str) -> list[RGBColor]:
|
||||
raw = PALETTES[canonical_palette_name(name)]
|
||||
return [hex_to_color(value) for value in raw]
|
||||
|
||||
|
||||
def sample_palette(name: str, amount: float) -> RGBColor:
|
||||
colors = palette_colors(name)
|
||||
if len(colors) == 1:
|
||||
return colors[0]
|
||||
amount = clamp(amount)
|
||||
scaled = amount * (len(colors) - 1)
|
||||
index = int(math.floor(scaled))
|
||||
next_index = min(len(colors) - 1, index + 1)
|
||||
local = scaled - index
|
||||
return colors[index].mix(colors[next_index], local)
|
||||
|
||||
|
||||
def sample_random_effect_color(amount: float) -> RGBColor:
|
||||
return sample_color_choices(RANDOM_EFFECT_COLORS, amount)
|
||||
|
||||
|
||||
def sample_color_choices(colors: tuple[RGBColor, ...], amount: float) -> RGBColor:
|
||||
amount = clamp(amount)
|
||||
if not colors:
|
||||
return RGBColor.white()
|
||||
index = int(math.floor(amount * len(colors))) % len(colors)
|
||||
return colors[index]
|
||||
|
||||
|
||||
def custom_random_color_choices(primary_hex: str, secondary_hex: str) -> tuple[RGBColor, ...]:
|
||||
primary = hex_to_color(primary_hex)
|
||||
secondary = hex_to_color(secondary_hex)
|
||||
if primary.to_8bit_tuple() == secondary.to_8bit_tuple():
|
||||
return (primary,)
|
||||
return (
|
||||
primary,
|
||||
primary.mix(secondary, 0.25),
|
||||
primary.mix(secondary, 0.5),
|
||||
primary.mix(secondary, 0.75),
|
||||
secondary,
|
||||
)
|
||||
|
||||
|
||||
def sample_custom_random_color(primary_hex: str, secondary_hex: str, amount: float) -> RGBColor:
|
||||
return sample_color_choices(custom_random_color_choices(primary_hex, secondary_hex), amount)
|
||||
|
||||
|
||||
def smoothstep(edge0: float, edge1: float, value: float) -> float:
|
||||
if edge0 == edge1:
|
||||
return 0.0
|
||||
amount = clamp((value - edge0) / (edge1 - edge0))
|
||||
return amount * amount * (3.0 - 2.0 * amount)
|
||||
|
||||
|
||||
def ease_in_out_sine(value: float) -> float:
|
||||
return -(math.cos(math.pi * clamp(value)) - 1.0) / 2.0
|
||||
|
||||
|
||||
def oscillate(time_s: float, speed: float = 1.0, phase: float = 0.0) -> float:
|
||||
return 0.5 + 0.5 * math.sin(time_s * speed * math.tau + phase)
|
||||
|
||||
|
||||
def brighten(color: RGBColor, amount: float) -> RGBColor:
|
||||
return color.mix(RGBColor.white(), amount)
|
||||
|
||||
|
||||
def darken(color: RGBColor, amount: float) -> RGBColor:
|
||||
return color.mix(RGBColor.black(), amount)
|
||||
|
||||
|
||||
def choose_pair(color_mode: str, primary_hex: str, secondary_hex: str, palette_name: str, amount: float) -> tuple[RGBColor, RGBColor]:
|
||||
primary = hex_to_color(primary_hex)
|
||||
secondary = hex_to_color(secondary_hex)
|
||||
|
||||
if color_mode == "palette":
|
||||
primary = sample_palette(palette_name, amount)
|
||||
secondary = sample_palette(palette_name, (amount + 0.38) % 1.0)
|
||||
elif color_mode == "random_colors":
|
||||
primary = sample_random_effect_color(amount)
|
||||
secondary = darken(primary, 0.78)
|
||||
elif color_mode == "custom_random":
|
||||
primary = sample_custom_random_color(primary_hex, secondary_hex, amount)
|
||||
secondary = darken(primary, 0.78)
|
||||
elif color_mode == "mono":
|
||||
# Mono should be a true single-color-on-black look so stepped patterns can
|
||||
# reach an actual off state instead of hovering at a dim gray floor.
|
||||
secondary = RGBColor.black()
|
||||
elif color_mode == "complementary":
|
||||
hue, lightness, saturation = colorsys.rgb_to_hls(primary.r, primary.g, primary.b)
|
||||
red, green, blue = colorsys.hls_to_rgb((hue + 0.5) % 1.0, lightness, saturation)
|
||||
secondary = RGBColor(red, green, blue)
|
||||
|
||||
return primary, secondary
|
||||
|
||||
|
||||
def relative_luminance(color: RGBColor) -> float:
|
||||
return 0.2126 * color.r + 0.7152 * color.g + 0.0722 * color.b
|
||||
|
||||
|
||||
def label_contrast(color: RGBColor) -> RGBColor:
|
||||
return RGBColor.black() if relative_luminance(color) > 0.6 else RGBColor.white()
|
||||
@@ -0,0 +1,609 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
import time
|
||||
import traceback
|
||||
|
||||
from app.qt_compat import QObject, QTimer, Qt, Signal
|
||||
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.config.xml_mapping import MappingValidationError, load_config, save_config
|
||||
from app.core.diagnostics import RealtimeDiagnostics
|
||||
from app.core.pattern_engine import PatternEngine
|
||||
from app.core.pattern_compat import normalize_pattern_request
|
||||
from app.core.presets import PresetRecord, PresetStore
|
||||
from app.core.types import PatternParameters, PreviewFrame, SceneState, SceneTransition, blend_preview_frames
|
||||
from app.output.manager import OutputManager
|
||||
|
||||
NUMERIC_PARAMETER_KEYS = {
|
||||
"brightness",
|
||||
"fade",
|
||||
"tempo_multiplier",
|
||||
"angle",
|
||||
"on_width",
|
||||
"off_width",
|
||||
"block_size",
|
||||
"pixel_group_size",
|
||||
"strobe_duty_cycle",
|
||||
"randomness",
|
||||
}
|
||||
|
||||
DEFAULT_TEMPO_BPM = 120.0
|
||||
MIN_TEMPO_BPM = 10.0
|
||||
MAX_TEMPO_BPM = 300.0
|
||||
DEFAULT_RENDER_FPS = 60.0
|
||||
DEFAULT_PREVIEW_FPS = 30.0
|
||||
PRIORITIZED_PREVIEW_FPS = 12.0
|
||||
|
||||
|
||||
def _clamp_tempo_bpm(value: float) -> float:
|
||||
return max(MIN_TEMPO_BPM, min(MAX_TEMPO_BPM, float(value)))
|
||||
|
||||
|
||||
def _legacy_speed_to_tempo_bpm(value: float) -> float:
|
||||
return _clamp_tempo_bpm(float(value) * 60.0)
|
||||
|
||||
|
||||
class InfinityMirrorController(QObject):
|
||||
frame_ready = Signal(object)
|
||||
next_frame_ready = Signal(object)
|
||||
state_changed = Signal()
|
||||
config_changed = Signal()
|
||||
presets_changed = Signal()
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, project_root: str | Path, output_manager: OutputManager | None = None) -> None:
|
||||
super().__init__()
|
||||
self.project_root = Path(project_root)
|
||||
self.output_manager = output_manager if output_manager is not None else OutputManager()
|
||||
self.preset_store = PresetStore(self.project_root / "presets")
|
||||
self.preset_store.ensure_seed_presets()
|
||||
|
||||
self.config = InfinityMirrorConfig()
|
||||
self.mapping_path: Path | None = None
|
||||
|
||||
self._live_scene = SceneState()
|
||||
self._next_scene = self._live_scene.clone()
|
||||
self.foh_mode_enabled = False
|
||||
self.tempo_bpm = DEFAULT_TEMPO_BPM
|
||||
self._tempo_anchor_time_s = 0.0
|
||||
self._tempo_anchor_phase = 0.0
|
||||
self._transport_time_s = 0.0
|
||||
self.transition_duration_s = 2.0
|
||||
self._scene_transition: SceneTransition | None = None
|
||||
|
||||
self.live_engine = PatternEngine()
|
||||
self.next_engine = PatternEngine()
|
||||
self._transition_engine: PatternEngine | None = None
|
||||
|
||||
self.utility_mode = "none"
|
||||
self.selected_tile_id: str | None = None
|
||||
self.current_frame: PreviewFrame | None = None
|
||||
self.next_frame: PreviewFrame | None = None
|
||||
self._last_output_message = ""
|
||||
self._frames_rendered = 0
|
||||
self._last_render_duration_s = 0.0
|
||||
self._render_window_started_at = 0.0
|
||||
self._render_window_count = 0
|
||||
self._render_fps = 0.0
|
||||
self._last_live_preview_emit_time_s = None
|
||||
self._last_next_preview_emit_time_s = None
|
||||
self._last_live_preview_emit_time_s: float | None = None
|
||||
self._last_next_preview_emit_time_s: float | None = None
|
||||
self.output_manager.update_config(self.config)
|
||||
|
||||
self.timer = QTimer(self)
|
||||
self.timer.setTimerType(Qt.PreciseTimer)
|
||||
self.timer.setInterval(max(1, int(round(1000.0 / DEFAULT_RENDER_FPS))))
|
||||
self.timer.timeout.connect(self.render_once)
|
||||
self.timer.start()
|
||||
|
||||
@property
|
||||
def pattern_id(self) -> str:
|
||||
return self._editable_scene().pattern_id
|
||||
|
||||
@pattern_id.setter
|
||||
def pattern_id(self, value: str) -> None:
|
||||
self.set_pattern(value)
|
||||
|
||||
@property
|
||||
def params(self) -> PatternParameters:
|
||||
return self._editable_scene().params.clone()
|
||||
|
||||
@params.setter
|
||||
def params(self, value: PatternParameters) -> None:
|
||||
self.set_params(value)
|
||||
|
||||
@property
|
||||
def transition_active(self) -> bool:
|
||||
return self._scene_transition is not None
|
||||
|
||||
def live_scene(self) -> SceneState:
|
||||
return self.scene_state("live")
|
||||
|
||||
def next_scene(self) -> SceneState:
|
||||
return self.scene_state("next")
|
||||
|
||||
def scene_state(self, role: str = "live") -> SceneState:
|
||||
return self._scene_for_role(role).clone()
|
||||
|
||||
def preview_frame_for(self, role: str = "live") -> PreviewFrame | None:
|
||||
return self.next_frame if str(role).strip().lower() == "next" else self.current_frame
|
||||
|
||||
def startup_mapping_candidates(self) -> list[Path]:
|
||||
return [
|
||||
self.project_root / "sample_data" / "infinity_mirror_mapping_clean.xml",
|
||||
self.project_root / "infinity_mirror_mapping_clean.xml",
|
||||
]
|
||||
|
||||
def load_initial_config(self) -> None:
|
||||
for candidate in self.startup_mapping_candidates():
|
||||
if candidate.exists():
|
||||
self.load_mapping(candidate)
|
||||
return
|
||||
self.status_message.emit("No default mapping found. Use File > Open Mapping.")
|
||||
|
||||
def load_mapping(self, path: str | Path) -> None:
|
||||
config = load_config(path)
|
||||
self.config = config
|
||||
self.mapping_path = Path(path)
|
||||
if self.selected_tile_id not in self.config.tile_lookup():
|
||||
first_tile = self.config.sorted_tiles()[0] if self.config.tiles else None
|
||||
self.selected_tile_id = first_tile.tile_id if first_tile else None
|
||||
self._reset_render_state()
|
||||
self.output_manager.update_config(self.config)
|
||||
self.config_changed.emit()
|
||||
self.state_changed.emit()
|
||||
self.status_message.emit(f"Loaded mapping: {Path(path).name}")
|
||||
self.render_once(force_preview=True)
|
||||
|
||||
def save_mapping(self, path: str | Path | None = None) -> None:
|
||||
target = Path(path) if path is not None else self.mapping_path
|
||||
if target is None:
|
||||
raise ValueError("No mapping path set.")
|
||||
save_config(self.config, target)
|
||||
self.mapping_path = target
|
||||
self.status_message.emit(f"Saved mapping: {target.name}")
|
||||
|
||||
def replace_config(self, config: InfinityMirrorConfig, path: str | Path | None = None) -> None:
|
||||
self.config = config
|
||||
if path is not None:
|
||||
self.mapping_path = Path(path)
|
||||
if self.selected_tile_id not in self.config.tile_lookup():
|
||||
first_tile = self.config.sorted_tiles()[0] if self.config.tiles else None
|
||||
self.selected_tile_id = first_tile.tile_id if first_tile else None
|
||||
self._reset_render_state()
|
||||
self.output_manager.update_config(self.config)
|
||||
self.config_changed.emit()
|
||||
self.state_changed.emit()
|
||||
self.render_once(force_preview=True)
|
||||
|
||||
def _reset_render_state(self) -> None:
|
||||
self.live_engine = PatternEngine()
|
||||
self.next_engine = PatternEngine()
|
||||
self._transition_engine = None
|
||||
self._scene_transition = None
|
||||
self.current_frame = None
|
||||
self.next_frame = None
|
||||
self._last_output_message = ""
|
||||
self._frames_rendered = 0
|
||||
self._last_render_duration_s = 0.0
|
||||
self._render_window_started_at = 0.0
|
||||
self._render_window_count = 0
|
||||
self._render_fps = 0.0
|
||||
|
||||
def _editable_scene(self) -> SceneState:
|
||||
return self._scene_for_role(self._editable_scene_role())
|
||||
|
||||
def _editable_scene_role(self) -> str:
|
||||
return "next" if self.foh_mode_enabled else "live"
|
||||
|
||||
def _normalize_scene_role(self, role: str = "live") -> str:
|
||||
return "next" if str(role).strip().lower() == "next" else "live"
|
||||
|
||||
def _scene_for_role(self, role: str = "live") -> SceneState:
|
||||
return self._next_scene if self._normalize_scene_role(role) == "next" else self._live_scene
|
||||
|
||||
def _replace_scene(self, role: str, scene: SceneState) -> None:
|
||||
cloned_scene = scene.clone()
|
||||
if self._normalize_scene_role(role) == "next":
|
||||
self._next_scene = cloned_scene
|
||||
else:
|
||||
self._live_scene = cloned_scene
|
||||
|
||||
def _editable_scene_snapshot(self) -> SceneState:
|
||||
return self._editable_scene().clone()
|
||||
|
||||
def _replace_editable_scene(self, scene: SceneState) -> None:
|
||||
self._replace_scene(self._editable_scene_role(), scene)
|
||||
|
||||
def _sync_next_to_live(self) -> None:
|
||||
self._next_scene = self._live_scene.clone()
|
||||
self.next_engine = PatternEngine()
|
||||
self.next_frame = None
|
||||
|
||||
def _normalize_pattern_request(self, pattern_id: str, params: PatternParameters | None = None) -> tuple[str, PatternParameters | None]:
|
||||
return normalize_pattern_request(
|
||||
pattern_id,
|
||||
params,
|
||||
rows=self.config.logical_display.rows,
|
||||
cols=self.config.logical_display.cols,
|
||||
)
|
||||
|
||||
def _render_scene_frame(
|
||||
self,
|
||||
engine: PatternEngine,
|
||||
scene: SceneState,
|
||||
timestamp: float,
|
||||
utility_mode: str = "none",
|
||||
) -> PreviewFrame:
|
||||
tempo_phase = self._tempo_phase_at(timestamp)
|
||||
return engine.render_frame(
|
||||
config=self.config,
|
||||
pattern_id=scene.pattern_id,
|
||||
params=scene.params,
|
||||
utility_mode=utility_mode,
|
||||
selected_tile_id=self.selected_tile_id,
|
||||
timestamp=timestamp,
|
||||
tempo_bpm=self.tempo_bpm,
|
||||
tempo_phase=tempo_phase,
|
||||
)
|
||||
|
||||
def _tempo_phase_at(self, timestamp: float) -> float:
|
||||
return self._tempo_anchor_phase + (float(timestamp) - self._tempo_anchor_time_s) * (self.tempo_bpm / 60.0)
|
||||
|
||||
def _retime_transport(self, tempo_bpm: float, timestamp: float) -> None:
|
||||
current_phase = self._tempo_phase_at(timestamp)
|
||||
self.tempo_bpm = tempo_bpm
|
||||
self._tempo_anchor_time_s = float(timestamp)
|
||||
self._tempo_anchor_phase = current_phase
|
||||
self._transport_time_s = float(timestamp)
|
||||
|
||||
def set_tempo_bpm(self, value: float, timestamp: float | None = None) -> None:
|
||||
tempo_bpm = _clamp_tempo_bpm(float(value))
|
||||
if abs(tempo_bpm - self.tempo_bpm) < 1e-9:
|
||||
return
|
||||
anchor_time = self._transport_time_s if timestamp is None else float(timestamp)
|
||||
self._retime_transport(tempo_bpm, anchor_time)
|
||||
self.state_changed.emit()
|
||||
self.render_once(timestamp=anchor_time if timestamp is not None else None, force_preview=True)
|
||||
|
||||
def set_foh_mode(self, enabled: bool) -> None:
|
||||
enabled = bool(enabled)
|
||||
if self.foh_mode_enabled == enabled:
|
||||
return
|
||||
self.foh_mode_enabled = enabled
|
||||
if not enabled:
|
||||
if self._scene_transition is None:
|
||||
self._sync_next_to_live()
|
||||
self.state_changed.emit()
|
||||
self.render_once(force_preview=True)
|
||||
|
||||
def set_transition_duration(self, duration_s: float) -> None:
|
||||
duration = max(0.0, min(60.0, float(duration_s)))
|
||||
if abs(duration - self.transition_duration_s) < 1e-9:
|
||||
return
|
||||
self.transition_duration_s = duration
|
||||
self.state_changed.emit()
|
||||
|
||||
def set_pattern(self, pattern_id: str) -> None:
|
||||
scene = self._editable_scene_snapshot()
|
||||
pattern_id, params = self._normalize_pattern_request(pattern_id, scene.params)
|
||||
scene.pattern_id = pattern_id
|
||||
if params is not None:
|
||||
scene.params = params
|
||||
self._replace_editable_scene(scene)
|
||||
if not self.foh_mode_enabled:
|
||||
self.utility_mode = "none"
|
||||
self._sync_next_to_live()
|
||||
self.state_changed.emit()
|
||||
self.render_once(force_preview=True)
|
||||
|
||||
def set_params(self, value: PatternParameters) -> None:
|
||||
scene = self._editable_scene_snapshot()
|
||||
params = value.clone().sanitized()
|
||||
if params == scene.params:
|
||||
return
|
||||
scene.params = params
|
||||
self._replace_editable_scene(scene)
|
||||
if not self.foh_mode_enabled:
|
||||
self._sync_next_to_live()
|
||||
self.state_changed.emit()
|
||||
self.render_once(force_preview=True)
|
||||
|
||||
def set_parameter(self, key: str, value) -> None:
|
||||
if key == "speed":
|
||||
try:
|
||||
self.set_tempo_bpm(_legacy_speed_to_tempo_bpm(float(value)))
|
||||
except (TypeError, ValueError) as exc:
|
||||
self.status_message.emit(f"Invalid value for {key}: {value!r} ({exc})")
|
||||
return
|
||||
|
||||
if key == "step_size":
|
||||
self.status_message.emit("Step Size has been retired. Use the global BPM control instead.")
|
||||
return
|
||||
|
||||
scene = self._editable_scene_snapshot()
|
||||
if not hasattr(scene.params, key):
|
||||
self.status_message.emit(f"Unknown parameter ignored: {key}")
|
||||
return
|
||||
|
||||
payload = {
|
||||
field_name: getattr(scene.params, field_name)
|
||||
for field_name in scene.params.__dataclass_fields__
|
||||
}
|
||||
try:
|
||||
if key in NUMERIC_PARAMETER_KEYS:
|
||||
payload[key] = float(value)
|
||||
else:
|
||||
payload[key] = value
|
||||
params = PatternParameters.from_dict(payload)
|
||||
except (TypeError, ValueError) as exc:
|
||||
self.status_message.emit(f"Invalid value for {key}: {value!r} ({exc})")
|
||||
return
|
||||
|
||||
if params == scene.params:
|
||||
return
|
||||
|
||||
scene.params = params
|
||||
self._replace_editable_scene(scene)
|
||||
if not self.foh_mode_enabled:
|
||||
self._sync_next_to_live()
|
||||
self.state_changed.emit()
|
||||
if not self.timer.isActive() or self.current_frame is None:
|
||||
self.render_once(force_preview=True)
|
||||
|
||||
def set_selected_tile(self, tile_id: str | None) -> None:
|
||||
self.selected_tile_id = tile_id
|
||||
self.state_changed.emit()
|
||||
|
||||
def set_backend(self, backend_id: str) -> None:
|
||||
self.output_manager.set_active_backend(backend_id)
|
||||
self.state_changed.emit()
|
||||
self.status_message.emit(f"Active backend: {self.output_manager.active_backend().display_name}")
|
||||
|
||||
def set_output_enabled(self, enabled: bool) -> None:
|
||||
self.output_manager.set_output_enabled(enabled)
|
||||
self.state_changed.emit()
|
||||
state = "enabled" if enabled else "disabled"
|
||||
self.status_message.emit(f"Hardware output {state}.")
|
||||
|
||||
def set_output_target_fps(self, value: float) -> None:
|
||||
self.output_manager.set_target_fps(value)
|
||||
self.state_changed.emit()
|
||||
|
||||
def set_utility_mode(self, utility_mode: str) -> None:
|
||||
self.utility_mode = utility_mode
|
||||
self.state_changed.emit()
|
||||
self.render_once(force_preview=True)
|
||||
|
||||
def clear_utility_mode(self) -> None:
|
||||
self.utility_mode = "none"
|
||||
self.state_changed.emit()
|
||||
self.render_once(force_preview=True)
|
||||
|
||||
def go_scene(self) -> None:
|
||||
self.utility_mode = "none"
|
||||
self._scene_transition = None
|
||||
self._transition_engine = None
|
||||
self._live_scene = self._next_scene.clone()
|
||||
self.live_engine = PatternEngine()
|
||||
if not self.foh_mode_enabled:
|
||||
self._sync_next_to_live()
|
||||
self.state_changed.emit()
|
||||
self.render_once(force_preview=True)
|
||||
self.status_message.emit("Go: next scene is now live.")
|
||||
|
||||
def fade_go(self, duration_s: float | None = None, timestamp: float | None = None) -> None:
|
||||
duration = self.transition_duration_s if duration_s is None else max(0.0, min(60.0, float(duration_s)))
|
||||
self.transition_duration_s = duration
|
||||
if duration <= 0.0:
|
||||
self.go_scene()
|
||||
return
|
||||
|
||||
now = time.perf_counter() if timestamp is None else timestamp
|
||||
if self.current_frame is None:
|
||||
self.render_once(timestamp=now, force_preview=True)
|
||||
|
||||
source_frame = self.current_frame
|
||||
if source_frame is None:
|
||||
source_frame = self._render_scene_frame(self.live_engine, self._live_scene, now, utility_mode=self.utility_mode)
|
||||
|
||||
self.utility_mode = "none"
|
||||
self._transition_engine = PatternEngine()
|
||||
self._scene_transition = SceneTransition(
|
||||
started_at=now,
|
||||
duration_s=duration,
|
||||
source_frame=source_frame,
|
||||
target_scene=self._next_scene.clone(),
|
||||
)
|
||||
self.state_changed.emit()
|
||||
self.render_once(timestamp=now, force_preview=True)
|
||||
self.status_message.emit(f"Fade Go: {duration:.1f}s transition started.")
|
||||
|
||||
def available_patterns(self):
|
||||
return self.live_engine.descriptors()
|
||||
|
||||
def available_presets(self) -> list[PresetRecord]:
|
||||
return self.preset_store.list_presets()
|
||||
|
||||
def save_current_preset(self, name: str) -> None:
|
||||
record = PresetRecord.create(name=name, pattern_id=self.pattern_id, params=self.params, tempo_bpm=self.tempo_bpm)
|
||||
self.preset_store.save(record)
|
||||
self.presets_changed.emit()
|
||||
self.status_message.emit(f"Saved preset: {name}")
|
||||
|
||||
def apply_preset(self, preset_name: str) -> None:
|
||||
record = self.preset_store.load(preset_name)
|
||||
params = PatternParameters.from_dict(record.parameters)
|
||||
pattern_id, normalized_params = self._normalize_pattern_request(record.pattern_id, params)
|
||||
scene = self._editable_scene_snapshot()
|
||||
scene.pattern_id = pattern_id
|
||||
scene.params = normalized_params if normalized_params is not None else params
|
||||
self._replace_editable_scene(scene)
|
||||
preset_tempo_bpm: float | None = None
|
||||
if record.tempo_bpm is not None:
|
||||
preset_tempo_bpm = _clamp_tempo_bpm(record.tempo_bpm)
|
||||
elif "speed" in record.parameters:
|
||||
try:
|
||||
preset_tempo_bpm = _legacy_speed_to_tempo_bpm(float(record.parameters["speed"]))
|
||||
except (TypeError, ValueError):
|
||||
pass
|
||||
if preset_tempo_bpm is not None:
|
||||
self._retime_transport(preset_tempo_bpm, self._transport_time_s)
|
||||
if not self.foh_mode_enabled:
|
||||
self.utility_mode = "none"
|
||||
self._sync_next_to_live()
|
||||
self.state_changed.emit()
|
||||
self.render_once(force_preview=True)
|
||||
self.status_message.emit(f"Loaded preset: {preset_name}")
|
||||
|
||||
def delete_preset(self, preset_name: str) -> None:
|
||||
self.preset_store.delete(preset_name)
|
||||
self.presets_changed.emit()
|
||||
self.status_message.emit(f"Deleted preset: {preset_name}")
|
||||
|
||||
def render_once(self, timestamp: float | None = None, force_preview: bool = False) -> None:
|
||||
if not self.config.tiles:
|
||||
return
|
||||
|
||||
render_started_at = time.perf_counter()
|
||||
now = time.perf_counter() if timestamp is None else timestamp
|
||||
self._transport_time_s = float(now)
|
||||
try:
|
||||
if self._scene_transition is None:
|
||||
live_frame = self._render_scene_frame(
|
||||
self.live_engine,
|
||||
self._live_scene,
|
||||
now,
|
||||
utility_mode=self.utility_mode,
|
||||
)
|
||||
else:
|
||||
if self._transition_engine is None:
|
||||
self._transition_engine = PatternEngine()
|
||||
target_frame = self._render_scene_frame(self._transition_engine, self._scene_transition.target_scene, now)
|
||||
elapsed = max(0.0, now - self._scene_transition.started_at)
|
||||
alpha = 1.0 if self._scene_transition.duration_s <= 0.0 else min(1.0, elapsed / self._scene_transition.duration_s)
|
||||
live_frame = blend_preview_frames(self._scene_transition.source_frame, target_frame, alpha)
|
||||
if alpha >= 1.0:
|
||||
self._live_scene = self._scene_transition.target_scene.clone()
|
||||
self.live_engine = self._transition_engine
|
||||
self._transition_engine = None
|
||||
self._scene_transition = None
|
||||
live_frame = target_frame
|
||||
if not self.foh_mode_enabled:
|
||||
self._sync_next_to_live()
|
||||
self.state_changed.emit()
|
||||
|
||||
self.current_frame = live_frame
|
||||
|
||||
if self.foh_mode_enabled:
|
||||
self.next_frame = self._render_scene_frame(self.next_engine, self._next_scene, now)
|
||||
elif self._next_scene.pattern_id == self._live_scene.pattern_id and self._next_scene.params.to_dict() == self._live_scene.params.to_dict():
|
||||
self.next_frame = live_frame
|
||||
|
||||
self.output_manager.submit_frame(live_frame)
|
||||
self._emit_preview_frames(live_frame, self.next_frame if self.foh_mode_enabled else None, now, force_preview)
|
||||
self._emit_output_status_messages()
|
||||
except Exception as exc: # pragma: no cover - UI safety net
|
||||
message = f"Render error: {exc}"
|
||||
if message != self._last_output_message:
|
||||
self._last_output_message = message
|
||||
self.status_message.emit(message)
|
||||
traceback.print_exc()
|
||||
finally:
|
||||
self._record_render_metrics(render_started_at, time.perf_counter())
|
||||
|
||||
def realtime_diagnostics(self) -> RealtimeDiagnostics:
|
||||
output = self.output_manager.diagnostics_snapshot()
|
||||
return RealtimeDiagnostics(
|
||||
backend_id=output.backend_id,
|
||||
backend_name=output.backend_name,
|
||||
output_enabled=output.output_enabled,
|
||||
worker_running=output.worker_running,
|
||||
target_output_fps=output.target_fps,
|
||||
render_fps=self._render_fps,
|
||||
send_fps=output.send_fps,
|
||||
last_render_time_ms=self._last_render_duration_s * 1000.0,
|
||||
last_send_time_ms=output.last_send_time_ms,
|
||||
frames_rendered=self._frames_rendered,
|
||||
frames_submitted=output.frames_submitted,
|
||||
frames_sent=output.frames_sent,
|
||||
stale_frame_drops=output.stale_frame_drops,
|
||||
send_failures=output.send_failures,
|
||||
packets_last_frame=output.packets_last_frame,
|
||||
devices_last_frame=output.devices_last_frame,
|
||||
packets_sent_total=output.packets_sent_total,
|
||||
last_output_message=output.last_message,
|
||||
send_budget_misses=output.send_budget_misses,
|
||||
last_schedule_slip_ms=output.last_schedule_slip_ms,
|
||||
controller_fps=output.controller_fps,
|
||||
controller_live_devices=output.controller_live_devices,
|
||||
controller_sampled_devices=output.controller_sampled_devices,
|
||||
controller_total_devices=output.controller_total_devices,
|
||||
controller_source=output.controller_source,
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
self.timer.stop()
|
||||
self.output_manager.shutdown()
|
||||
|
||||
def safe_load_mapping(self, path: str | Path) -> tuple[bool, list[str]]:
|
||||
try:
|
||||
self.load_mapping(path)
|
||||
return True, []
|
||||
except MappingValidationError as exc:
|
||||
return False, exc.errors
|
||||
|
||||
def _emit_output_status_messages(self) -> None:
|
||||
for message in self.output_manager.drain_status_messages():
|
||||
if message != self._last_output_message:
|
||||
self._last_output_message = message
|
||||
self.status_message.emit(message)
|
||||
|
||||
def _record_render_metrics(self, started_at: float, finished_at: float) -> None:
|
||||
self._last_render_duration_s = max(0.0, finished_at - started_at)
|
||||
self._frames_rendered += 1
|
||||
if self._render_window_started_at <= 0.0:
|
||||
self._render_window_started_at = finished_at
|
||||
self._render_window_count = 1
|
||||
self._render_fps = 0.0
|
||||
return
|
||||
|
||||
self._render_window_count += 1
|
||||
elapsed = finished_at - self._render_window_started_at
|
||||
if elapsed >= 0.5:
|
||||
self._render_fps = self._render_window_count / elapsed
|
||||
self._render_window_started_at = finished_at
|
||||
self._render_window_count = 0
|
||||
|
||||
def _emit_preview_frames(
|
||||
self,
|
||||
live_frame: PreviewFrame,
|
||||
next_frame: PreviewFrame | None,
|
||||
timestamp: float,
|
||||
force_preview: bool,
|
||||
) -> None:
|
||||
if self._preview_emit_due(self._last_live_preview_emit_time_s, timestamp, force_preview):
|
||||
self._last_live_preview_emit_time_s = timestamp
|
||||
self.frame_ready.emit(live_frame)
|
||||
|
||||
if next_frame is not None and self._preview_emit_due(self._last_next_preview_emit_time_s, timestamp, force_preview):
|
||||
self._last_next_preview_emit_time_s = timestamp
|
||||
self.next_frame_ready.emit(next_frame)
|
||||
|
||||
def _preview_emit_due(self, last_emit_time_s: float | None, timestamp: float, force_preview: bool) -> bool:
|
||||
if force_preview or last_emit_time_s is None:
|
||||
return True
|
||||
if float(timestamp) <= last_emit_time_s:
|
||||
return True
|
||||
return (float(timestamp) - last_emit_time_s) >= self._preview_emit_interval_s()
|
||||
|
||||
def _preview_emit_interval_s(self) -> float:
|
||||
try:
|
||||
backend = self.output_manager.active_backend()
|
||||
live_output_active = self.output_manager.output_enabled and backend.supports_live_output
|
||||
except Exception:
|
||||
live_output_active = False
|
||||
preview_fps = PRIORITIZED_PREVIEW_FPS if live_output_active else DEFAULT_PREVIEW_FPS
|
||||
return 1.0 / max(1.0, preview_fps)
|
||||
@@ -0,0 +1,32 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RealtimeDiagnostics:
|
||||
backend_id: str
|
||||
backend_name: str
|
||||
output_enabled: bool
|
||||
worker_running: bool
|
||||
target_output_fps: float
|
||||
render_fps: float
|
||||
send_fps: float
|
||||
last_render_time_ms: float
|
||||
last_send_time_ms: float
|
||||
frames_rendered: int
|
||||
frames_submitted: int
|
||||
frames_sent: int
|
||||
stale_frame_drops: int
|
||||
send_failures: int
|
||||
packets_last_frame: int
|
||||
devices_last_frame: int
|
||||
packets_sent_total: int
|
||||
last_output_message: str = ""
|
||||
send_budget_misses: int = 0
|
||||
last_schedule_slip_ms: float = 0.0
|
||||
controller_fps: float | None = None
|
||||
controller_live_devices: int = 0
|
||||
controller_sampled_devices: int = 0
|
||||
controller_total_devices: int = 0
|
||||
controller_source: str = ""
|
||||
@@ -0,0 +1,89 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config.models import SegmentConfig, TileConfig
|
||||
from app.core.types import clamp
|
||||
|
||||
NormalizedPoint = tuple[float, float]
|
||||
NormalizedInsets = tuple[float, float]
|
||||
|
||||
_SEGMENT_SIDE_ALIASES = {
|
||||
"left": "left",
|
||||
"right": "right",
|
||||
"top": "top",
|
||||
"bottom": "bottom",
|
||||
"l": "left",
|
||||
"r": "right",
|
||||
"t": "top",
|
||||
"b": "bottom",
|
||||
}
|
||||
|
||||
|
||||
def segment_side(tile: TileConfig, segment: SegmentConfig) -> str | None:
|
||||
side = (segment.side or "").strip().lower()
|
||||
if side in _SEGMENT_SIDE_ALIASES:
|
||||
return _SEGMENT_SIDE_ALIASES[side]
|
||||
|
||||
width = max(0.001, tile.x1 - tile.x0)
|
||||
height = max(0.001, tile.y1 - tile.y0)
|
||||
delta_x = abs(segment.x1 - segment.x0) / width
|
||||
delta_y = abs(segment.y1 - segment.y0) / height
|
||||
mid_x = (segment.x0 + segment.x1) / 2.0
|
||||
mid_y = (segment.y0 + segment.y1) / 2.0
|
||||
|
||||
if delta_x >= delta_y:
|
||||
return "top" if mid_y <= tile.y0 + height * 0.5 else "bottom"
|
||||
return "left" if mid_x <= tile.x0 + width * 0.5 else "right"
|
||||
|
||||
|
||||
def segment_led_position(
|
||||
tile: TileConfig,
|
||||
segment: SegmentConfig,
|
||||
amount: float,
|
||||
*,
|
||||
insets: NormalizedInsets = (0.0, 0.0),
|
||||
apply_reverse: bool = False,
|
||||
) -> NormalizedPoint:
|
||||
amount = clamp(float(amount))
|
||||
if apply_reverse and segment.reverse:
|
||||
amount = 1.0 - amount
|
||||
|
||||
inset_x = clamp(float(insets[0]), 0.0, 0.49)
|
||||
inset_y = clamp(float(insets[1]), 0.0, 0.49)
|
||||
side = segment_side(tile, segment)
|
||||
|
||||
if side == "left":
|
||||
return inset_x, inset_y + (1.0 - inset_y * 2.0) * amount
|
||||
if side == "right":
|
||||
return 1.0 - inset_x, inset_y + (1.0 - inset_y * 2.0) * amount
|
||||
if side == "top":
|
||||
return inset_x + (1.0 - inset_x * 2.0) * amount, inset_y
|
||||
if side == "bottom":
|
||||
return inset_x + (1.0 - inset_x * 2.0) * amount, 1.0 - inset_y
|
||||
|
||||
width = max(0.001, tile.x1 - tile.x0)
|
||||
height = max(0.001, tile.y1 - tile.y0)
|
||||
x_pos = segment.x0 + (segment.x1 - segment.x0) * amount
|
||||
y_pos = segment.y0 + (segment.y1 - segment.y0) * amount
|
||||
return (
|
||||
clamp((x_pos - tile.x0) / width),
|
||||
clamp((y_pos - tile.y0) / height),
|
||||
)
|
||||
|
||||
|
||||
def segment_led_positions(
|
||||
tile: TileConfig,
|
||||
segment: SegmentConfig,
|
||||
*,
|
||||
insets: NormalizedInsets = (0.0, 0.0),
|
||||
) -> list[NormalizedPoint]:
|
||||
count = max(1, segment.led_count)
|
||||
return [
|
||||
segment_led_position(
|
||||
tile,
|
||||
segment,
|
||||
0.0 if count == 1 else index / (count - 1),
|
||||
insets=insets,
|
||||
apply_reverse=True,
|
||||
)
|
||||
for index in range(count)
|
||||
]
|
||||
@@ -0,0 +1,125 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.core.types import PatternParameters, clamp
|
||||
|
||||
|
||||
SCAN_ANGLES: tuple[int, ...] = (0, 45, 90, 135, 180, 225, 270, 315)
|
||||
|
||||
_LEGACY_SCAN_IDS = {
|
||||
"row_chase",
|
||||
"row_scan",
|
||||
"column_chase",
|
||||
"column_scan",
|
||||
"diagonal_scan",
|
||||
}
|
||||
|
||||
_DEFAULT_SCAN_PARAMS = PatternParameters()
|
||||
|
||||
|
||||
def nearest_scan_angle(value: float) -> int:
|
||||
angle = int(round(float(value))) % 360
|
||||
return min(SCAN_ANGLES, key=lambda candidate: min((candidate - angle) % 360, (angle - candidate) % 360))
|
||||
|
||||
|
||||
def coerce_bool(value: object, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
def _flip_angle_horizontal(angle: int) -> int:
|
||||
return (180 - angle) % 360
|
||||
|
||||
|
||||
def _flip_angle_vertical(angle: int) -> int:
|
||||
return (-angle) % 360
|
||||
|
||||
|
||||
def normalize_pattern_request(
|
||||
pattern_id: str,
|
||||
params: PatternParameters | None = None,
|
||||
rows: int | None = None,
|
||||
cols: int | None = None,
|
||||
) -> tuple[str, PatternParameters | None]:
|
||||
if pattern_id == "pixel_sparkle":
|
||||
if params is None:
|
||||
return "strobe", None
|
||||
payload = params.to_dict()
|
||||
payload["strobe_mode"] = "random_pixels"
|
||||
return "strobe", PatternParameters.from_dict(payload)
|
||||
|
||||
if pattern_id == "random_blocks":
|
||||
return "sparkle", params
|
||||
|
||||
if pattern_id == "scan" and params is not None:
|
||||
payload = params.to_dict()
|
||||
angle = nearest_scan_angle(getattr(params, "angle", payload.get("angle", 0.0)))
|
||||
if coerce_bool(getattr(params, "flip_horizontal", False)):
|
||||
angle = _flip_angle_horizontal(angle)
|
||||
if coerce_bool(getattr(params, "flip_vertical", False)):
|
||||
angle = _flip_angle_vertical(angle)
|
||||
payload["angle"] = angle
|
||||
payload.pop("band_thickness", None)
|
||||
payload.pop("flip_horizontal", None)
|
||||
payload.pop("flip_vertical", None)
|
||||
return "scan", PatternParameters.from_dict(payload)
|
||||
|
||||
if pattern_id not in _LEGACY_SCAN_IDS:
|
||||
return pattern_id, params
|
||||
|
||||
payload = params.to_dict() if params is not None else {}
|
||||
if params is not None:
|
||||
payload["flip_horizontal"] = getattr(params, "flip_horizontal", payload.get("flip_horizontal", False))
|
||||
payload["flip_vertical"] = getattr(params, "flip_vertical", payload.get("flip_vertical", False))
|
||||
payload["band_thickness"] = getattr(params, "band_thickness", payload.get("band_thickness", params.on_width))
|
||||
direction = str(payload.get("direction", "left_to_right"))
|
||||
diagonal_mode = str(payload.get("diagonal_scan_mode", "line"))
|
||||
|
||||
def preferred_value(key: str, legacy_value):
|
||||
current_value = payload.get(key, getattr(_DEFAULT_SCAN_PARAMS, key))
|
||||
default_value = getattr(_DEFAULT_SCAN_PARAMS, key)
|
||||
return legacy_value if current_value == default_value else current_value
|
||||
|
||||
if pattern_id in {"row_chase", "row_scan"}:
|
||||
angle = 90 if direction != "bottom_to_top" else 270
|
||||
on_width = float(preferred_value("on_width", payload.get("block_size", 1.0)))
|
||||
off_width = float(preferred_value("off_width", 0.0))
|
||||
scan_style = str(preferred_value("scan_style", "line"))
|
||||
elif pattern_id in {"column_chase", "column_scan"}:
|
||||
angle = 0 if direction != "right_to_left" else 180
|
||||
on_width = float(preferred_value("on_width", payload.get("block_size", 1.0)))
|
||||
off_width = float(preferred_value("off_width", 0.0))
|
||||
scan_style = str(preferred_value("scan_style", "line"))
|
||||
else:
|
||||
angle = {
|
||||
"left_to_right": 315,
|
||||
"right_to_left": 135,
|
||||
"top_to_bottom": 315,
|
||||
"bottom_to_top": 135,
|
||||
}.get(direction, 315)
|
||||
scan_style = "bands" if diagonal_mode == "bands" else "line"
|
||||
scan_style = str(preferred_value("scan_style", scan_style))
|
||||
on_width = float(preferred_value("on_width", 2.0 if scan_style == "bands" else 0.5))
|
||||
off_width = float(preferred_value("off_width", 1.5 if scan_style == "bands" else 0.0))
|
||||
|
||||
angle = nearest_scan_angle(preferred_value("angle", angle))
|
||||
if coerce_bool(preferred_value("flip_horizontal", False)):
|
||||
angle = _flip_angle_horizontal(angle)
|
||||
if coerce_bool(preferred_value("flip_vertical", False)):
|
||||
angle = _flip_angle_vertical(angle)
|
||||
payload["angle"] = angle
|
||||
payload["scan_style"] = str(scan_style)
|
||||
payload["on_width"] = clamp(float(on_width), 0.1, 2.0)
|
||||
payload["off_width"] = clamp(float(off_width), 0.0, 2.0)
|
||||
payload.pop("band_thickness", None)
|
||||
payload.pop("flip_horizontal", None)
|
||||
payload.pop("flip_vertical", None)
|
||||
return "scan", PatternParameters.from_dict(payload)
|
||||
@@ -0,0 +1,324 @@
|
||||
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}
|
||||
@@ -0,0 +1,90 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, asdict
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
import json
|
||||
import re
|
||||
|
||||
from .types import PatternParameters
|
||||
|
||||
|
||||
@dataclass
|
||||
class PresetRecord:
|
||||
name: str
|
||||
pattern_id: str
|
||||
parameters: dict
|
||||
brightness: float
|
||||
palette: str
|
||||
created_at: str
|
||||
tempo_bpm: float | None = None
|
||||
|
||||
@classmethod
|
||||
def create(cls, name: str, pattern_id: str, params: PatternParameters, tempo_bpm: float | None = None) -> "PresetRecord":
|
||||
return cls(
|
||||
name=name,
|
||||
pattern_id=pattern_id,
|
||||
parameters=params.to_dict(),
|
||||
brightness=params.brightness,
|
||||
palette=params.palette,
|
||||
created_at=datetime.utcnow().isoformat(timespec="seconds"),
|
||||
tempo_bpm=tempo_bpm,
|
||||
)
|
||||
|
||||
|
||||
class PresetStore:
|
||||
def __init__(self, root: str | Path) -> None:
|
||||
self.root = Path(root)
|
||||
self.root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
def list_presets(self) -> list[PresetRecord]:
|
||||
presets: list[PresetRecord] = []
|
||||
for path in sorted(self.root.glob("*.json")):
|
||||
try:
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
presets.append(PresetRecord(**payload))
|
||||
except (OSError, json.JSONDecodeError, TypeError):
|
||||
continue
|
||||
return presets
|
||||
|
||||
def save(self, record: PresetRecord) -> Path:
|
||||
path = self.root / f"{slugify(record.name)}.json"
|
||||
path.write_text(json.dumps(asdict(record), indent=2), encoding="utf-8")
|
||||
return path
|
||||
|
||||
def load(self, name: str) -> PresetRecord:
|
||||
path = self.root / f"{slugify(name)}.json"
|
||||
payload = json.loads(path.read_text(encoding="utf-8"))
|
||||
return PresetRecord(**payload)
|
||||
|
||||
def delete(self, name: str) -> None:
|
||||
path = self.root / f"{slugify(name)}.json"
|
||||
if path.exists():
|
||||
path.unlink()
|
||||
|
||||
def ensure_seed_presets(self) -> None:
|
||||
if any(self.root.glob("*.json")):
|
||||
return
|
||||
seeds = [
|
||||
PresetRecord.create(
|
||||
"Afterhours Pulse",
|
||||
"center_pulse",
|
||||
PatternParameters(palette="Afterhours", color_mode="palette", fade=0.28),
|
||||
),
|
||||
PresetRecord.create(
|
||||
"Laser Chase",
|
||||
"scan_dual",
|
||||
PatternParameters(palette="Laser Club", block_size=1.6, direction="left_to_right"),
|
||||
),
|
||||
PresetRecord.create(
|
||||
"Heat Breathing",
|
||||
"breathing",
|
||||
PatternParameters(palette="Warehouse Heat", color_mode="palette", fade=0.7),
|
||||
),
|
||||
]
|
||||
for preset in seeds:
|
||||
self.save(preset)
|
||||
|
||||
|
||||
def slugify(value: str) -> str:
|
||||
return re.sub(r"[^a-z0-9]+", "-", value.lower()).strip("-") or "preset"
|
||||
@@ -0,0 +1,342 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass, field, replace
|
||||
from typing import Any
|
||||
|
||||
|
||||
def clamp(value: float, minimum: float = 0.0, maximum: float = 1.0) -> float:
|
||||
return max(minimum, min(maximum, value))
|
||||
|
||||
|
||||
_SCAN_ANGLES = (0, 45, 90, 135, 180, 225, 270, 315)
|
||||
|
||||
|
||||
def _nearest_scan_angle(value: float) -> int:
|
||||
angle = int(round(float(value))) % 360
|
||||
return min(_SCAN_ANGLES, key=lambda candidate: min((candidate - angle) % 360, (angle - candidate) % 360))
|
||||
|
||||
|
||||
def _coerce_bool(value: object, default: bool = False) -> bool:
|
||||
if isinstance(value, bool):
|
||||
return value
|
||||
if isinstance(value, (int, float)):
|
||||
return bool(value)
|
||||
if isinstance(value, str):
|
||||
normalized = value.strip().lower()
|
||||
if normalized in {"1", "true", "yes", "on"}:
|
||||
return True
|
||||
if normalized in {"0", "false", "no", "off"}:
|
||||
return False
|
||||
return default
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class RGBColor:
|
||||
r: float
|
||||
g: float
|
||||
b: float
|
||||
|
||||
def clamped(self) -> "RGBColor":
|
||||
return RGBColor(clamp(self.r), clamp(self.g), clamp(self.b))
|
||||
|
||||
def scaled(self, factor: float) -> "RGBColor":
|
||||
return RGBColor(self.r * factor, self.g * factor, self.b * factor).clamped()
|
||||
|
||||
def mix(self, other: "RGBColor", amount: float) -> "RGBColor":
|
||||
amount = clamp(amount)
|
||||
return RGBColor(
|
||||
self.r + (other.r - self.r) * amount,
|
||||
self.g + (other.g - self.g) * amount,
|
||||
self.b + (other.b - self.b) * amount,
|
||||
).clamped()
|
||||
|
||||
def to_8bit_tuple(self) -> tuple[int, int, int]:
|
||||
value = self.clamped()
|
||||
return int(value.r * 255), int(value.g * 255), int(value.b * 255)
|
||||
|
||||
def to_hex(self) -> str:
|
||||
red, green, blue = self.to_8bit_tuple()
|
||||
return f"#{red:02X}{green:02X}{blue:02X}"
|
||||
|
||||
@staticmethod
|
||||
def black() -> "RGBColor":
|
||||
return RGBColor(0.0, 0.0, 0.0)
|
||||
|
||||
@staticmethod
|
||||
def white() -> "RGBColor":
|
||||
return RGBColor(1.0, 1.0, 1.0)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatternParameters:
|
||||
speed: float = 0.45
|
||||
brightness: float = 1.0
|
||||
fade: float = 0.35
|
||||
tempo_multiplier: float = 1.0
|
||||
direction: str = "left_to_right"
|
||||
checker_mode: str = "classic"
|
||||
scan_style: str = "line"
|
||||
angle: float = 0.0
|
||||
on_width: float = 1.0
|
||||
off_width: float = 1.0
|
||||
band_thickness: float = 0.8
|
||||
flip_horizontal: bool = False
|
||||
flip_vertical: bool = False
|
||||
strobe_mode: str = "global"
|
||||
stopwatch_mode: str = "sync"
|
||||
color_mode: str = "dual"
|
||||
primary_color: str = "#4D7CFF"
|
||||
secondary_color: str = "#0E1630"
|
||||
palette: str = "Laser Club"
|
||||
symmetry: str = "none"
|
||||
center_pulse_mode: str = "expand"
|
||||
step_size: float = 1.0
|
||||
block_size: float = 1.0
|
||||
pixel_group_size: float = 1.0
|
||||
strobe_duty_cycle: float = 0.5
|
||||
randomness: float = 0.35
|
||||
|
||||
def clone(self) -> "PatternParameters":
|
||||
return replace(self)
|
||||
|
||||
def sanitized(self) -> "PatternParameters":
|
||||
return PatternParameters(
|
||||
speed=max(0.01, self.speed),
|
||||
brightness=clamp(self.brightness, 0.0, 2.0),
|
||||
fade=clamp(self.fade),
|
||||
tempo_multiplier=clamp(self.tempo_multiplier, 0.25, 8.0),
|
||||
direction=self.direction,
|
||||
checker_mode=self.checker_mode,
|
||||
scan_style=self.scan_style if self.scan_style in {"line", "bands"} else "line",
|
||||
angle=_nearest_scan_angle(self.angle),
|
||||
on_width=clamp(self.on_width, 0.1, 2.0),
|
||||
off_width=clamp(self.off_width, 0.0, 2.0),
|
||||
band_thickness=clamp(self.band_thickness, 0.1, 2.0),
|
||||
flip_horizontal=bool(self.flip_horizontal),
|
||||
flip_vertical=bool(self.flip_vertical),
|
||||
strobe_mode=self.strobe_mode,
|
||||
stopwatch_mode=self.stopwatch_mode,
|
||||
color_mode=self.color_mode,
|
||||
primary_color=self.primary_color,
|
||||
secondary_color=self.secondary_color,
|
||||
palette=self.palette,
|
||||
symmetry=self.symmetry,
|
||||
center_pulse_mode=self.center_pulse_mode if self.center_pulse_mode in {"expand", "reverse", "outline", "outline_reverse"} else "expand",
|
||||
step_size=max(0.1, self.step_size),
|
||||
block_size=max(0.1, self.block_size),
|
||||
pixel_group_size=max(1.0, min(5.0, round(self.pixel_group_size))),
|
||||
strobe_duty_cycle=clamp(self.strobe_duty_cycle, 0.02, 0.98),
|
||||
randomness=clamp(self.randomness, 0.0, 1.5),
|
||||
)
|
||||
|
||||
def to_dict(self) -> dict[str, Any]:
|
||||
return {
|
||||
"brightness": self.brightness,
|
||||
"fade": self.fade,
|
||||
"tempo_multiplier": self.tempo_multiplier,
|
||||
"direction": self.direction,
|
||||
"checker_mode": self.checker_mode,
|
||||
"scan_style": self.scan_style,
|
||||
"angle": self.angle,
|
||||
"on_width": self.on_width,
|
||||
"off_width": self.off_width,
|
||||
"strobe_mode": self.strobe_mode,
|
||||
"stopwatch_mode": self.stopwatch_mode,
|
||||
"color_mode": self.color_mode,
|
||||
"primary_color": self.primary_color,
|
||||
"secondary_color": self.secondary_color,
|
||||
"palette": self.palette,
|
||||
"symmetry": self.symmetry,
|
||||
"center_pulse_mode": self.center_pulse_mode,
|
||||
"block_size": self.block_size,
|
||||
"pixel_group_size": self.pixel_group_size,
|
||||
"strobe_duty_cycle": self.strobe_duty_cycle,
|
||||
"randomness": self.randomness,
|
||||
}
|
||||
|
||||
@classmethod
|
||||
def from_dict(cls, payload: dict[str, Any]) -> "PatternParameters":
|
||||
return cls(
|
||||
speed=float(payload.get("speed", cls.speed)),
|
||||
brightness=float(payload.get("brightness", cls.brightness)),
|
||||
fade=float(payload.get("fade", cls.fade)),
|
||||
tempo_multiplier=float(payload.get("tempo_multiplier", cls.tempo_multiplier)),
|
||||
direction=str(payload.get("direction", cls.direction)),
|
||||
checker_mode=str(payload.get("checker_mode", cls.checker_mode)),
|
||||
scan_style=str(payload.get("scan_style", payload.get("diagonal_scan_mode", cls.scan_style))),
|
||||
angle=float(payload.get("angle", cls.angle)),
|
||||
on_width=float(payload.get("on_width", cls.on_width)),
|
||||
off_width=float(payload.get("off_width", cls.off_width)),
|
||||
band_thickness=float(payload.get("band_thickness", payload.get("on_width", cls.band_thickness))),
|
||||
flip_horizontal=_coerce_bool(payload.get("flip_horizontal", cls.flip_horizontal)),
|
||||
flip_vertical=_coerce_bool(payload.get("flip_vertical", cls.flip_vertical)),
|
||||
strobe_mode=str(payload.get("strobe_mode", cls.strobe_mode)),
|
||||
stopwatch_mode=str(payload.get("stopwatch_mode", cls.stopwatch_mode)),
|
||||
color_mode=str(payload.get("color_mode", cls.color_mode)),
|
||||
primary_color=str(payload.get("primary_color", cls.primary_color)),
|
||||
secondary_color=str(payload.get("secondary_color", cls.secondary_color)),
|
||||
palette=str(payload.get("palette", cls.palette)),
|
||||
symmetry=str(payload.get("symmetry", cls.symmetry)),
|
||||
center_pulse_mode=str(payload.get("center_pulse_mode", cls.center_pulse_mode)),
|
||||
step_size=float(payload.get("step_size", cls.step_size)),
|
||||
block_size=float(payload.get("block_size", cls.block_size)),
|
||||
pixel_group_size=float(payload.get("pixel_group_size", cls.pixel_group_size)),
|
||||
strobe_duty_cycle=float(payload.get("strobe_duty_cycle", cls.strobe_duty_cycle)),
|
||||
randomness=float(payload.get("randomness", cls.randomness)),
|
||||
).sanitized()
|
||||
|
||||
|
||||
@dataclass
|
||||
class SceneState:
|
||||
pattern_id: str = "solid"
|
||||
params: PatternParameters = field(default_factory=PatternParameters)
|
||||
|
||||
def clone(self) -> "SceneState":
|
||||
return SceneState(
|
||||
pattern_id=self.pattern_id,
|
||||
params=self.params.clone(),
|
||||
)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TilePatternSample:
|
||||
fill_color: RGBColor
|
||||
glow_color: RGBColor
|
||||
rim_color: RGBColor
|
||||
label_color: RGBColor
|
||||
intensity: float = 1.0
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class TileFrame:
|
||||
tile_id: str
|
||||
row: int
|
||||
col: int
|
||||
fill_color: RGBColor
|
||||
glow_color: RGBColor
|
||||
rim_color: RGBColor
|
||||
label_color: RGBColor
|
||||
intensity: float
|
||||
enabled: bool
|
||||
led_pixels: dict[str, list[RGBColor]] = field(default_factory=dict)
|
||||
metadata: dict[str, Any] = field(default_factory=dict)
|
||||
|
||||
|
||||
@dataclass
|
||||
class PreviewFrame:
|
||||
timestamp: float
|
||||
pattern_id: str
|
||||
utility_mode: str
|
||||
background_start: RGBColor
|
||||
background_end: RGBColor
|
||||
tiles: dict[str, TileFrame]
|
||||
|
||||
|
||||
@dataclass
|
||||
class SceneTransition:
|
||||
started_at: float
|
||||
duration_s: float
|
||||
source_frame: PreviewFrame
|
||||
target_scene: SceneState
|
||||
|
||||
|
||||
def _blend_led_pixel_groups(
|
||||
source_pixels: dict[str, list[RGBColor]],
|
||||
target_pixels: dict[str, list[RGBColor]],
|
||||
amount: float,
|
||||
) -> dict[str, list[RGBColor]]:
|
||||
if not source_pixels and not target_pixels:
|
||||
return {}
|
||||
|
||||
blended: dict[str, list[RGBColor]] = {}
|
||||
black = RGBColor.black()
|
||||
for segment_name in sorted(set(source_pixels) | set(target_pixels)):
|
||||
source_segment = source_pixels.get(segment_name, [])
|
||||
target_segment = target_pixels.get(segment_name, [])
|
||||
count = max(len(source_segment), len(target_segment))
|
||||
segment_colors: list[RGBColor] = []
|
||||
for index in range(count):
|
||||
source_color = source_segment[index] if index < len(source_segment) else black
|
||||
target_color = target_segment[index] if index < len(target_segment) else black
|
||||
segment_colors.append(source_color.mix(target_color, amount))
|
||||
blended[segment_name] = segment_colors
|
||||
return blended
|
||||
|
||||
|
||||
def _blend_metadata(source: dict[str, Any], target: dict[str, Any], amount: float) -> dict[str, Any]:
|
||||
if not source and not target:
|
||||
return {}
|
||||
|
||||
blended = {
|
||||
key: value
|
||||
for key, value in source.items()
|
||||
if key not in {"diagonal_split", "led_pixels"}
|
||||
}
|
||||
for key, value in target.items():
|
||||
if key not in {"diagonal_split", "led_pixels"}:
|
||||
blended[key] = value
|
||||
|
||||
source_split = source.get("diagonal_split")
|
||||
target_split = target.get("diagonal_split")
|
||||
if isinstance(source_split, dict) or isinstance(target_split, dict):
|
||||
source_split = source_split if isinstance(source_split, dict) else {}
|
||||
target_split = target_split if isinstance(target_split, dict) else {}
|
||||
source_a = source_split.get("color_a", RGBColor.black())
|
||||
source_b = source_split.get("color_b", RGBColor.black())
|
||||
target_a = target_split.get("color_a", RGBColor.black())
|
||||
target_b = target_split.get("color_b", RGBColor.black())
|
||||
if isinstance(source_a, RGBColor) and isinstance(source_b, RGBColor) and isinstance(target_a, RGBColor) and isinstance(target_b, RGBColor):
|
||||
blended["diagonal_split"] = {
|
||||
"orientation": str(target_split.get("orientation", source_split.get("orientation", "slash"))),
|
||||
"color_a": source_a.mix(target_a, amount),
|
||||
"color_b": source_b.mix(target_b, amount),
|
||||
}
|
||||
|
||||
source_pixels = source.get("led_pixels")
|
||||
target_pixels = target.get("led_pixels")
|
||||
if isinstance(source_pixels, dict) or isinstance(target_pixels, dict):
|
||||
source_pixels = source_pixels if isinstance(source_pixels, dict) else {}
|
||||
target_pixels = target_pixels if isinstance(target_pixels, dict) else {}
|
||||
blended["led_pixels"] = _blend_led_pixel_groups(source_pixels, target_pixels, amount)
|
||||
|
||||
return blended
|
||||
|
||||
|
||||
def blend_preview_frames(source: PreviewFrame, target: PreviewFrame, amount: float) -> PreviewFrame:
|
||||
amount = clamp(amount)
|
||||
blended_tiles: dict[str, TileFrame] = {}
|
||||
for tile_id in sorted(set(source.tiles) | set(target.tiles)):
|
||||
source_tile = source.tiles.get(tile_id)
|
||||
target_tile = target.tiles.get(tile_id)
|
||||
if source_tile is None and target_tile is not None:
|
||||
blended_tiles[tile_id] = target_tile
|
||||
continue
|
||||
if target_tile is None and source_tile is not None:
|
||||
blended_tiles[tile_id] = source_tile
|
||||
continue
|
||||
if source_tile is None or target_tile is None:
|
||||
continue
|
||||
|
||||
blended_tiles[tile_id] = TileFrame(
|
||||
tile_id=target_tile.tile_id,
|
||||
row=target_tile.row,
|
||||
col=target_tile.col,
|
||||
fill_color=source_tile.fill_color.mix(target_tile.fill_color, amount),
|
||||
glow_color=source_tile.glow_color.mix(target_tile.glow_color, amount),
|
||||
rim_color=source_tile.rim_color.mix(target_tile.rim_color, amount),
|
||||
label_color=source_tile.label_color.mix(target_tile.label_color, amount),
|
||||
intensity=source_tile.intensity + (target_tile.intensity - source_tile.intensity) * amount,
|
||||
enabled=source_tile.enabled and target_tile.enabled,
|
||||
led_pixels=_blend_led_pixel_groups(source_tile.led_pixels, target_tile.led_pixels, amount),
|
||||
metadata=_blend_metadata(source_tile.metadata, target_tile.metadata, amount),
|
||||
)
|
||||
|
||||
return PreviewFrame(
|
||||
timestamp=target.timestamp,
|
||||
pattern_id=target.pattern_id if amount >= 0.5 else source.pattern_id,
|
||||
utility_mode=target.utility_mode if amount >= 0.5 else source.utility_mode,
|
||||
background_start=source.background_start.mix(target.background_start, amount),
|
||||
background_end=source.background_end.mix(target.background_end, amount),
|
||||
tiles=blended_tiles,
|
||||
)
|
||||
+35
@@ -0,0 +1,35 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
from app.qt_compat import QApplication, QT_API, QT_IMPORT_ERROR
|
||||
|
||||
|
||||
def main() -> int:
|
||||
from app.core.controller import InfinityMirrorController
|
||||
from app.ui.main_window import MainWindow
|
||||
from app.ui.theme import apply_dark_theme
|
||||
|
||||
if QApplication is None:
|
||||
print("No supported Qt binding could be loaded.")
|
||||
if QT_IMPORT_ERROR is not None:
|
||||
print(f"Import error: {QT_IMPORT_ERROR}")
|
||||
return 1
|
||||
|
||||
app = QApplication(sys.argv)
|
||||
apply_dark_theme(app)
|
||||
|
||||
project_root = Path(__file__).resolve().parents[1]
|
||||
controller = InfinityMirrorController(project_root)
|
||||
controller.load_initial_config()
|
||||
app.aboutToQuit.connect(controller.shutdown)
|
||||
|
||||
window = MainWindow(controller)
|
||||
window.statusBar().showMessage(f"Using Qt binding: {QT_API}", 5000)
|
||||
window.show()
|
||||
return app.exec()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
raise SystemExit(main())
|
||||
@@ -0,0 +1,23 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from .wled import (
|
||||
DiscoveredWledDevice,
|
||||
build_scan_hosts,
|
||||
discover_wled_devices,
|
||||
fetch_wled_info,
|
||||
identify_wled_device,
|
||||
normalize_mac_address,
|
||||
probe_wled_device,
|
||||
scan_candidate_subnets,
|
||||
)
|
||||
|
||||
__all__ = [
|
||||
"DiscoveredWledDevice",
|
||||
"build_scan_hosts",
|
||||
"discover_wled_devices",
|
||||
"fetch_wled_info",
|
||||
"identify_wled_device",
|
||||
"normalize_mac_address",
|
||||
"probe_wled_device",
|
||||
"scan_candidate_subnets",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,292 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from concurrent.futures import ThreadPoolExecutor, as_completed
|
||||
from dataclasses import dataclass
|
||||
import ipaddress
|
||||
import json
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
from typing import Callable, Iterable, Sequence
|
||||
from urllib.error import HTTPError, URLError
|
||||
from urllib.request import Request, urlopen
|
||||
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
|
||||
WLED_INFO_TIMEOUT_S = 0.35
|
||||
WLED_DISCOVERY_WORKERS = 32
|
||||
WLED_DDP_PORT = 4048
|
||||
WLED_DDP_HEADER_LENGTH = 10
|
||||
WLED_DDP_MAX_DATA_LENGTH = 1440
|
||||
WLED_DDP_VERSION_1 = 0x40
|
||||
WLED_DDP_PUSH_FLAG = 0x01
|
||||
WLED_DDP_RGB888 = 0x0B
|
||||
WLED_DDP_DESTINATION_ID = 1
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class DiscoveredWledDevice:
|
||||
ip_address: str
|
||||
hostname: str = ""
|
||||
instance_name: str = ""
|
||||
mac_address: str = ""
|
||||
led_count: int = 0
|
||||
info_endpoint: str = ""
|
||||
|
||||
|
||||
def normalize_mac_address(value: str) -> str:
|
||||
raw = str(value or "").strip().replace("-", ":").upper()
|
||||
if not raw:
|
||||
return ""
|
||||
parts = [part.zfill(2) for part in raw.split(":") if part]
|
||||
if len(parts) == 6 and all(len(part) == 2 for part in parts):
|
||||
return ":".join(parts)
|
||||
return raw
|
||||
|
||||
|
||||
def fetch_wled_info(host: str, timeout_s: float = WLED_INFO_TIMEOUT_S) -> tuple[dict[str, object], str] | None:
|
||||
normalized_host = str(host or "").strip()
|
||||
if not normalized_host:
|
||||
return None
|
||||
|
||||
for endpoint in ("/json/info", "/json"):
|
||||
payload = _load_json(f"http://{normalized_host}{endpoint}", timeout_s=timeout_s)
|
||||
if not isinstance(payload, dict):
|
||||
continue
|
||||
if endpoint == "/json/info":
|
||||
if _looks_like_wled_info(payload):
|
||||
return payload, endpoint
|
||||
continue
|
||||
info = payload.get("info")
|
||||
if isinstance(info, dict) and _looks_like_wled_info(info):
|
||||
return info, endpoint
|
||||
return None
|
||||
|
||||
|
||||
def probe_wled_device(host: str, timeout_s: float = WLED_INFO_TIMEOUT_S) -> DiscoveredWledDevice | None:
|
||||
info_result = fetch_wled_info(host, timeout_s=timeout_s)
|
||||
if info_result is None:
|
||||
return None
|
||||
|
||||
info, endpoint = info_result
|
||||
hostname = _string_value(info.get("mdns")) or _reverse_dns_name(host)
|
||||
instance_name = _string_value(info.get("name"))
|
||||
mac_address = normalize_mac_address(_string_value(info.get("mac")))
|
||||
leds = info.get("leds")
|
||||
led_count = 0
|
||||
if isinstance(leds, dict):
|
||||
count = leds.get("count")
|
||||
if isinstance(count, str):
|
||||
try:
|
||||
led_count = int(count)
|
||||
except ValueError:
|
||||
led_count = 0
|
||||
elif isinstance(count, (int, float)):
|
||||
led_count = int(count)
|
||||
|
||||
return DiscoveredWledDevice(
|
||||
ip_address=str(host).strip(),
|
||||
hostname=hostname,
|
||||
instance_name=instance_name,
|
||||
mac_address=mac_address,
|
||||
led_count=max(0, led_count),
|
||||
info_endpoint=endpoint,
|
||||
)
|
||||
|
||||
|
||||
def scan_candidate_subnets(config: InfinityMirrorConfig | None = None) -> list[ipaddress.IPv4Network]:
|
||||
networks: list[ipaddress.IPv4Network] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for address in _candidate_ipv4_addresses(config):
|
||||
try:
|
||||
ip_value = ipaddress.ip_address(address)
|
||||
except ValueError:
|
||||
continue
|
||||
if not isinstance(ip_value, ipaddress.IPv4Address):
|
||||
continue
|
||||
if ip_value.is_loopback or ip_value.is_link_local or ip_value.is_unspecified:
|
||||
continue
|
||||
network = ipaddress.ip_network(f"{ip_value}/24", strict=False)
|
||||
key = str(network)
|
||||
if key in seen:
|
||||
continue
|
||||
seen.add(key)
|
||||
networks.append(network)
|
||||
|
||||
return sorted(networks, key=lambda network: int(network.network_address))
|
||||
|
||||
|
||||
def build_scan_hosts(config: InfinityMirrorConfig | None = None, max_subnets: int = 3) -> list[str]:
|
||||
preferred_hosts = [tile.controller_ip.strip() for tile in config.sorted_tiles()] if config is not None else []
|
||||
prioritized = [host for host in preferred_hosts if host]
|
||||
|
||||
hosts: list[str] = []
|
||||
seen: set[str] = set()
|
||||
|
||||
for host in prioritized:
|
||||
if host not in seen:
|
||||
seen.add(host)
|
||||
hosts.append(host)
|
||||
|
||||
for network in scan_candidate_subnets(config)[: max(1, max_subnets)]:
|
||||
for host in network.hosts():
|
||||
text = str(host)
|
||||
if text in seen:
|
||||
continue
|
||||
seen.add(text)
|
||||
hosts.append(text)
|
||||
|
||||
return hosts
|
||||
|
||||
|
||||
def discover_wled_devices(
|
||||
hosts: Sequence[str],
|
||||
*,
|
||||
timeout_s: float = WLED_INFO_TIMEOUT_S,
|
||||
max_workers: int = WLED_DISCOVERY_WORKERS,
|
||||
progress_callback: Callable[[int, int, DiscoveredWledDevice | None], None] | None = None,
|
||||
) -> list[DiscoveredWledDevice]:
|
||||
unique_hosts = [str(host).strip() for host in hosts if str(host).strip()]
|
||||
total = len(unique_hosts)
|
||||
if total == 0:
|
||||
return []
|
||||
|
||||
devices: list[DiscoveredWledDevice] = []
|
||||
completed = 0
|
||||
seen_device_keys: set[str] = set()
|
||||
|
||||
with ThreadPoolExecutor(max_workers=max(1, min(max_workers, total))) as executor:
|
||||
future_map = {
|
||||
executor.submit(probe_wled_device, host, timeout_s): host
|
||||
for host in unique_hosts
|
||||
}
|
||||
for future in as_completed(future_map):
|
||||
completed += 1
|
||||
device: DiscoveredWledDevice | None = None
|
||||
try:
|
||||
device = future.result()
|
||||
except Exception:
|
||||
device = None
|
||||
|
||||
if device is not None:
|
||||
key = device.mac_address or device.ip_address
|
||||
if key not in seen_device_keys:
|
||||
seen_device_keys.add(key)
|
||||
devices.append(device)
|
||||
|
||||
if progress_callback is not None:
|
||||
progress_callback(completed, total, device)
|
||||
|
||||
return sorted(devices, key=lambda item: tuple(int(part) for part in item.ip_address.split(".")))
|
||||
|
||||
|
||||
def identify_wled_device(
|
||||
host: str,
|
||||
*,
|
||||
led_count: int | None = None,
|
||||
duration_s: float = 1.6,
|
||||
pulse_interval_s: float = 0.22,
|
||||
) -> None:
|
||||
device = probe_wled_device(host, timeout_s=max(WLED_INFO_TIMEOUT_S, 0.45))
|
||||
if device is None:
|
||||
raise OSError(f"WLED device at {host} is unreachable.")
|
||||
|
||||
pixel_count = int(led_count or device.led_count)
|
||||
if pixel_count <= 0:
|
||||
raise ValueError(f"Unable to determine LED count for {host}.")
|
||||
|
||||
on_payload = bytes((255, 32, 32)) * pixel_count
|
||||
off_payload = bytes((0, 0, 0)) * pixel_count
|
||||
|
||||
deadline = time.monotonic() + max(0.2, float(duration_s))
|
||||
pulse_delay = max(0.08, float(pulse_interval_s))
|
||||
sequence = 1
|
||||
visible_phase = True
|
||||
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
while True:
|
||||
_send_ddp_frame(sock, host, on_payload if visible_phase else off_payload, sequence)
|
||||
sequence = 1 if sequence >= 15 else sequence + 1
|
||||
|
||||
remaining = deadline - time.monotonic()
|
||||
if remaining <= 0:
|
||||
break
|
||||
time.sleep(min(pulse_delay, remaining))
|
||||
visible_phase = not visible_phase
|
||||
|
||||
_send_ddp_frame(sock, host, off_payload, sequence)
|
||||
|
||||
|
||||
def _candidate_ipv4_addresses(config: InfinityMirrorConfig | None = None) -> Iterable[str]:
|
||||
if config is not None:
|
||||
for tile in config.sorted_tiles():
|
||||
controller_ip = tile.controller_ip.strip()
|
||||
if controller_ip:
|
||||
yield controller_ip
|
||||
|
||||
yielded: set[str] = set()
|
||||
for host in {socket.gethostname(), socket.getfqdn()}:
|
||||
try:
|
||||
_, _, addresses = socket.gethostbyname_ex(host)
|
||||
except OSError:
|
||||
continue
|
||||
for address in addresses:
|
||||
if address and address not in yielded:
|
||||
yielded.add(address)
|
||||
yield address
|
||||
|
||||
try:
|
||||
with socket.socket(socket.AF_INET, socket.SOCK_DGRAM) as sock:
|
||||
sock.connect(("8.8.8.8", 80))
|
||||
address = sock.getsockname()[0]
|
||||
if address and address not in yielded:
|
||||
yield address
|
||||
except OSError:
|
||||
return
|
||||
|
||||
|
||||
def _load_json(url: str, *, timeout_s: float) -> dict[str, object] | None:
|
||||
request = Request(url, headers={"Accept": "application/json"})
|
||||
try:
|
||||
with urlopen(request, timeout=timeout_s) as response:
|
||||
payload = json.loads(response.read().decode("utf-8"))
|
||||
except (HTTPError, URLError, OSError, TimeoutError, json.JSONDecodeError, UnicodeDecodeError):
|
||||
return None
|
||||
return payload if isinstance(payload, dict) else None
|
||||
|
||||
|
||||
def _looks_like_wled_info(payload: dict[str, object]) -> bool:
|
||||
if not isinstance(payload, dict):
|
||||
return False
|
||||
if "leds" not in payload:
|
||||
return False
|
||||
return any(key in payload for key in ("name", "ver", "mac"))
|
||||
|
||||
|
||||
def _reverse_dns_name(host: str) -> str:
|
||||
try:
|
||||
name, _, _ = socket.gethostbyaddr(host)
|
||||
except OSError:
|
||||
return ""
|
||||
return "" if name == host else name
|
||||
|
||||
|
||||
def _string_value(value: object) -> str:
|
||||
return value.strip() if isinstance(value, str) else ""
|
||||
|
||||
|
||||
def _send_ddp_frame(sock: socket.socket, host: str, payload: bytes, sequence: int) -> None:
|
||||
for offset in range(0, len(payload), WLED_DDP_MAX_DATA_LENGTH):
|
||||
chunk = payload[offset : offset + WLED_DDP_MAX_DATA_LENGTH]
|
||||
last = offset + WLED_DDP_MAX_DATA_LENGTH >= len(payload)
|
||||
header = struct.pack(
|
||||
"!BBBBLH",
|
||||
WLED_DDP_VERSION_1 | (WLED_DDP_PUSH_FLAG if last else 0),
|
||||
sequence,
|
||||
WLED_DDP_RGB888,
|
||||
WLED_DDP_DESTINATION_ID,
|
||||
offset,
|
||||
len(chunk),
|
||||
)
|
||||
sock.sendto(header + chunk, (host, WLED_DDP_PORT))
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Output backend interfaces and implementations."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import defaultdict
|
||||
import socket
|
||||
import struct
|
||||
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.core.types import PreviewFrame
|
||||
|
||||
from .base import OutputBackend, OutputResult
|
||||
|
||||
|
||||
class ArtnetOutputBackend(OutputBackend):
|
||||
backend_id = "artnet"
|
||||
display_name = "Art-Net"
|
||||
supports_live_output = True
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._socket: socket.socket | None = None
|
||||
self._sequence = 0
|
||||
|
||||
def start(self) -> None:
|
||||
if self._socket is None:
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
|
||||
def send_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
|
||||
try:
|
||||
self.start()
|
||||
packets = self._build_packets(config, frame)
|
||||
for host, subnet, universe, payload in packets:
|
||||
packet = self._create_artnet_packet(subnet=subnet, universe=universe, payload=payload)
|
||||
self._socket.sendto(packet, (host, 6454))
|
||||
device_count = len({host for host, _, _, _ in packets})
|
||||
return OutputResult(
|
||||
ok=True,
|
||||
message=f"Sent {len(packets)} Art-Net packet(s).",
|
||||
packets_sent=len(packets),
|
||||
device_count=device_count,
|
||||
)
|
||||
except OSError as exc:
|
||||
return OutputResult(ok=False, message=f"Art-Net send failed: {exc}")
|
||||
|
||||
def _build_packets(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> list[tuple[str, int, int, bytes]]:
|
||||
grouped: dict[tuple[str, int, int], bytearray] = defaultdict(lambda: bytearray(512))
|
||||
|
||||
for tile in config.sorted_tiles():
|
||||
tile_frame = frame.tiles.get(tile.tile_id)
|
||||
if not tile.enabled or tile_frame is None:
|
||||
continue
|
||||
host = tile.controller_ip or "127.0.0.1"
|
||||
key = (host, tile.subnet, tile.universe)
|
||||
universe = grouped[key]
|
||||
for segment in tile.segments:
|
||||
segment_colors = tile_frame.led_pixels.get(segment.name, [])
|
||||
base_index = max(0, segment.start_channel - 1)
|
||||
for led_index, color in enumerate(segment_colors):
|
||||
channel_index = base_index + led_index * 3
|
||||
if channel_index + 2 >= len(universe):
|
||||
break
|
||||
red, green, blue = color.to_8bit_tuple()
|
||||
universe[channel_index] = red
|
||||
universe[channel_index + 1] = green
|
||||
universe[channel_index + 2] = blue
|
||||
|
||||
return [(host, subnet, universe, bytes(payload)) for (host, subnet, universe), payload in grouped.items()]
|
||||
|
||||
def _create_artnet_packet(self, subnet: int, universe: int, payload: bytes) -> bytes:
|
||||
self._sequence = (self._sequence + 1) % 256
|
||||
header = b"Art-Net\x00"
|
||||
opcode = struct.pack("<H", 0x5000)
|
||||
prot_ver = struct.pack(">H", 14)
|
||||
sequence = struct.pack("B", self._sequence)
|
||||
physical = struct.pack("B", 0)
|
||||
port_address = ((subnet & 0x0F) << 4) | (universe & 0x0F)
|
||||
subnet_universe = struct.pack("<H", port_address)
|
||||
length = struct.pack(">H", len(payload))
|
||||
return header + opcode + prot_ver + sequence + physical + subnet_universe + length + payload
|
||||
@@ -0,0 +1,70 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.core.types import PreviewFrame
|
||||
|
||||
|
||||
@dataclass
|
||||
class OutputResult:
|
||||
ok: bool
|
||||
message: str = ""
|
||||
packets_sent: int = 0
|
||||
device_count: int = 0
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ControllerMetrics:
|
||||
fps: float | None = None
|
||||
live_devices: int = 0
|
||||
sampled_devices: int = 0
|
||||
total_devices: int = 0
|
||||
source: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class OutputDiagnostics:
|
||||
backend_id: str
|
||||
backend_name: str
|
||||
output_enabled: bool
|
||||
worker_running: bool
|
||||
target_fps: float
|
||||
send_fps: float = 0.0
|
||||
last_send_time_ms: float = 0.0
|
||||
frames_submitted: int = 0
|
||||
frames_sent: int = 0
|
||||
stale_frame_drops: int = 0
|
||||
send_failures: int = 0
|
||||
packets_last_frame: int = 0
|
||||
devices_last_frame: int = 0
|
||||
packets_sent_total: int = 0
|
||||
last_message: str = ""
|
||||
send_budget_misses: int = 0
|
||||
last_schedule_slip_ms: float = 0.0
|
||||
controller_fps: float | None = None
|
||||
controller_live_devices: int = 0
|
||||
controller_sampled_devices: int = 0
|
||||
controller_total_devices: int = 0
|
||||
controller_source: str = ""
|
||||
|
||||
|
||||
class OutputBackend(ABC):
|
||||
backend_id: str
|
||||
display_name: str
|
||||
supports_live_output: bool = False
|
||||
|
||||
def start(self) -> None:
|
||||
"""Hook for backends that need to allocate resources."""
|
||||
|
||||
def stop(self) -> None:
|
||||
"""Hook for backends that need to release resources."""
|
||||
|
||||
def controller_metrics(self, config: InfinityMirrorConfig) -> ControllerMetrics | None:
|
||||
"""Optional hook for backends that can report controller-side receive metrics."""
|
||||
return None
|
||||
|
||||
@abstractmethod
|
||||
def send_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
|
||||
raise NotImplementedError
|
||||
@@ -0,0 +1,205 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import socket
|
||||
import struct
|
||||
import time
|
||||
|
||||
from app.config.models import InfinityMirrorConfig, TileConfig
|
||||
from app.core.types import PreviewFrame, TileFrame
|
||||
from app.network.wled import fetch_wled_info
|
||||
|
||||
from .base import ControllerMetrics, OutputBackend, OutputResult
|
||||
|
||||
DDP_DEFAULT_PORT = 4048
|
||||
DDP_HEADER_LENGTH = 10
|
||||
DDP_MAX_DATA_LENGTH = 1440
|
||||
DDP_VERSION_1 = 0x40
|
||||
DDP_PUSH_FLAG = 0x01
|
||||
DDP_RGB888 = 0x0B
|
||||
DDP_DEFAULT_DESTINATION_ID = 1
|
||||
WLED_INFO_TIMEOUT_S = 0.25
|
||||
WLED_INFO_SOURCE = "WLED /json/info leds.fps (live only)"
|
||||
DDP_UNCHANGED_HOST_KEEPALIVE_S = 0.35
|
||||
|
||||
|
||||
class DdpOutputBackend(OutputBackend):
|
||||
backend_id = "ddp"
|
||||
display_name = "DDP (WLED)"
|
||||
supports_live_output = True
|
||||
|
||||
def __init__(self, port: int = DDP_DEFAULT_PORT, destination_id: int = DDP_DEFAULT_DESTINATION_ID) -> None:
|
||||
self.port = int(port)
|
||||
self.destination_id = int(destination_id)
|
||||
self._socket: socket.socket | None = None
|
||||
self._sequence = 0
|
||||
self._last_payload_by_host: dict[str, bytes] = {}
|
||||
self._last_payload_sent_at: dict[str, float] = {}
|
||||
|
||||
def start(self) -> None:
|
||||
if self._socket is None:
|
||||
self._socket = socket.socket(socket.AF_INET, socket.SOCK_DGRAM)
|
||||
|
||||
def stop(self) -> None:
|
||||
if self._socket is not None:
|
||||
self._socket.close()
|
||||
self._socket = None
|
||||
self._last_payload_by_host.clear()
|
||||
self._last_payload_sent_at.clear()
|
||||
|
||||
def controller_metrics(self, config: InfinityMirrorConfig) -> ControllerMetrics | None:
|
||||
hosts = self._controller_hosts(config)
|
||||
if not hosts:
|
||||
return ControllerMetrics(source=WLED_INFO_SOURCE)
|
||||
|
||||
fps_values: list[float] = []
|
||||
sampled_devices = 0
|
||||
live_devices = 0
|
||||
for host in hosts:
|
||||
info = self._fetch_wled_info(host)
|
||||
if not isinstance(info, dict):
|
||||
continue
|
||||
sampled_devices += 1
|
||||
live = bool(info.get("live"))
|
||||
leds = info.get("leds")
|
||||
fps = leds.get("fps") if isinstance(leds, dict) else None
|
||||
if not live:
|
||||
continue
|
||||
live_devices += 1
|
||||
if isinstance(fps, (int, float)):
|
||||
fps_values.append(float(fps))
|
||||
elif isinstance(fps, str):
|
||||
try:
|
||||
fps_values.append(float(fps))
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
average_fps = (sum(fps_values) / len(fps_values)) if fps_values else None
|
||||
return ControllerMetrics(
|
||||
fps=average_fps,
|
||||
live_devices=live_devices,
|
||||
sampled_devices=sampled_devices,
|
||||
total_devices=len(hosts),
|
||||
source=WLED_INFO_SOURCE,
|
||||
)
|
||||
|
||||
def send_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
|
||||
try:
|
||||
self.start()
|
||||
packets = self._build_packets(config, frame)
|
||||
for host, sequence, offset, payload, last in packets:
|
||||
packet = self._create_ddp_packet(sequence=sequence, offset=offset, payload=payload, last=last)
|
||||
self._socket.sendto(packet, (host, self.port))
|
||||
device_count = len({host for host, _, _, _, _ in packets})
|
||||
return OutputResult(
|
||||
ok=True,
|
||||
message=f"Sent {len(packets)} DDP packet(s).",
|
||||
packets_sent=len(packets),
|
||||
device_count=device_count,
|
||||
)
|
||||
except OSError as exc:
|
||||
return OutputResult(ok=False, message=f"DDP send failed: {exc}")
|
||||
|
||||
def _build_packets(
|
||||
self,
|
||||
config: InfinityMirrorConfig,
|
||||
frame: PreviewFrame,
|
||||
) -> list[tuple[str, int, int, bytes, bool]]:
|
||||
packets: list[tuple[str, int, int, bytes, bool]] = []
|
||||
sequence = self._next_sequence()
|
||||
payloads = self._filter_redundant_payloads(self._build_device_payloads(config, frame), time.perf_counter())
|
||||
|
||||
for host, payload in payloads.items():
|
||||
for offset in range(0, len(payload), DDP_MAX_DATA_LENGTH):
|
||||
chunk = payload[offset : offset + DDP_MAX_DATA_LENGTH]
|
||||
last = offset + DDP_MAX_DATA_LENGTH >= len(payload)
|
||||
packets.append((host, sequence, offset, chunk, last))
|
||||
return packets
|
||||
|
||||
def _build_device_payloads(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> dict[str, bytes]:
|
||||
host_payloads: dict[str, bytearray] = {}
|
||||
|
||||
for tile in config.sorted_tiles():
|
||||
tile_frame = frame.tiles.get(tile.tile_id)
|
||||
if not tile.enabled or tile_frame is None:
|
||||
continue
|
||||
|
||||
payload = self._tile_payload(tile, tile_frame)
|
||||
if not payload:
|
||||
continue
|
||||
|
||||
host = tile.controller_ip or "127.0.0.1"
|
||||
host_payloads.setdefault(host, bytearray()).extend(payload)
|
||||
|
||||
return {host: bytes(payload) for host, payload in host_payloads.items()}
|
||||
|
||||
def _tile_payload(self, tile: TileConfig, tile_frame: TileFrame) -> bytes:
|
||||
required_length = tile.led_total * 3
|
||||
for segment in tile.segments:
|
||||
segment_colors = tile_frame.led_pixels.get(segment.name, [])
|
||||
segment_end = max(0, segment.start_channel - 1) + (len(segment_colors) * 3)
|
||||
required_length = max(required_length, segment_end)
|
||||
|
||||
if required_length <= 0:
|
||||
return b""
|
||||
|
||||
payload = bytearray(required_length)
|
||||
for segment in sorted(tile.segments, key=lambda item: (item.start_channel, item.name)):
|
||||
segment_colors = tile_frame.led_pixels.get(segment.name, [])
|
||||
base_index = max(0, segment.start_channel - 1)
|
||||
for led_index, color in enumerate(segment_colors):
|
||||
channel_index = base_index + led_index * 3
|
||||
if channel_index + 2 >= len(payload):
|
||||
break
|
||||
red, green, blue = color.to_8bit_tuple()
|
||||
payload[channel_index] = red
|
||||
payload[channel_index + 1] = green
|
||||
payload[channel_index + 2] = blue
|
||||
|
||||
return bytes(payload)
|
||||
|
||||
def _create_ddp_packet(self, sequence: int, offset: int, payload: bytes, last: bool) -> bytes:
|
||||
header = struct.pack(
|
||||
"!BBBBLH",
|
||||
DDP_VERSION_1 | (DDP_PUSH_FLAG if last else 0),
|
||||
sequence,
|
||||
DDP_RGB888,
|
||||
self.destination_id,
|
||||
offset,
|
||||
len(payload),
|
||||
)
|
||||
return header + payload
|
||||
|
||||
def _next_sequence(self) -> int:
|
||||
self._sequence = (self._sequence % 15) + 1
|
||||
return self._sequence
|
||||
|
||||
def _controller_hosts(self, config: InfinityMirrorConfig) -> list[str]:
|
||||
return sorted({tile.controller_ip.strip() for tile in config.sorted_tiles() if tile.enabled and tile.controller_ip.strip()})
|
||||
|
||||
def _fetch_wled_info(self, host: str) -> dict[str, object] | None:
|
||||
result = fetch_wled_info(host, timeout_s=WLED_INFO_TIMEOUT_S)
|
||||
if result is None:
|
||||
return None
|
||||
payload, _endpoint = result
|
||||
return payload
|
||||
|
||||
def _filter_redundant_payloads(self, host_payloads: dict[str, bytes], now: float) -> dict[str, bytes]:
|
||||
filtered: dict[str, bytes] = {}
|
||||
active_hosts = set(host_payloads)
|
||||
|
||||
for stale_host in set(self._last_payload_by_host) - active_hosts:
|
||||
self._last_payload_by_host.pop(stale_host, None)
|
||||
self._last_payload_sent_at.pop(stale_host, None)
|
||||
|
||||
for host, payload in host_payloads.items():
|
||||
previous_payload = self._last_payload_by_host.get(host)
|
||||
last_sent_at = self._last_payload_sent_at.get(host, 0.0)
|
||||
unchanged = previous_payload == payload
|
||||
keepalive_due = (now - last_sent_at) >= DDP_UNCHANGED_HOST_KEEPALIVE_S
|
||||
if unchanged and not keepalive_due:
|
||||
continue
|
||||
filtered[host] = payload
|
||||
self._last_payload_by_host[host] = payload
|
||||
self._last_payload_sent_at[host] = now
|
||||
|
||||
return filtered
|
||||
@@ -0,0 +1,391 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import deque
|
||||
import threading
|
||||
import time
|
||||
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.core.types import PreviewFrame
|
||||
|
||||
from .artnet import ArtnetOutputBackend
|
||||
from .base import ControllerMetrics, OutputBackend, OutputDiagnostics, OutputResult
|
||||
from .ddp import DdpOutputBackend
|
||||
from .preview import PreviewOutputBackend
|
||||
|
||||
DEFAULT_OUTPUT_FPS = 40.0
|
||||
MIN_OUTPUT_FPS = 1.0
|
||||
MAX_OUTPUT_FPS = 60.0
|
||||
class OutputManager:
|
||||
def __init__(self, target_fps: float = DEFAULT_OUTPUT_FPS) -> None:
|
||||
self.backends: dict[str, OutputBackend] = {
|
||||
PreviewOutputBackend.backend_id: PreviewOutputBackend(),
|
||||
DdpOutputBackend.backend_id: DdpOutputBackend(),
|
||||
ArtnetOutputBackend.backend_id: ArtnetOutputBackend(),
|
||||
}
|
||||
self.active_backend_id = PreviewOutputBackend.backend_id
|
||||
self.output_enabled = False
|
||||
|
||||
self._target_fps = self._clamp_target_fps(target_fps)
|
||||
self._config_snapshot: InfinityMirrorConfig | None = None
|
||||
self._config_source_id: int | None = None
|
||||
self._latest_frame: PreviewFrame | None = None
|
||||
self._latest_frame_version = 0
|
||||
self._sent_frame_version = 0
|
||||
|
||||
self._frames_submitted = 0
|
||||
self._frames_sent = 0
|
||||
self._stale_frame_drops = 0
|
||||
self._send_failures = 0
|
||||
self._packets_last_frame = 0
|
||||
self._devices_last_frame = 0
|
||||
self._packets_sent_total = 0
|
||||
self._last_send_duration_s = 0.0
|
||||
self._send_budget_misses = 0
|
||||
self._last_schedule_slip_s = 0.0
|
||||
self._last_result_message = ""
|
||||
self._send_window_started_at = 0.0
|
||||
self._send_window_count = 0
|
||||
self._send_fps = 0.0
|
||||
self._controller_metrics = ControllerMetrics(
|
||||
source="Controller-side FPS polling disabled during live output for stability.",
|
||||
)
|
||||
self._pending_messages: deque[str] = deque()
|
||||
self._last_queued_message = ""
|
||||
|
||||
self._lock = threading.Lock()
|
||||
self._condition = threading.Condition(self._lock)
|
||||
self._worker_thread: threading.Thread | None = None
|
||||
self._worker_stop_requested = False
|
||||
self._telemetry_thread: threading.Thread | None = None
|
||||
self._telemetry_stop_requested = False
|
||||
|
||||
def __del__(self) -> None: # pragma: no cover - destructor timing is interpreter-dependent
|
||||
try:
|
||||
self.shutdown()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def backend_names(self) -> list[tuple[str, str]]:
|
||||
return [(backend_id, backend.display_name) for backend_id, backend in self.backends.items()]
|
||||
|
||||
def set_active_backend(self, backend_id: str) -> None:
|
||||
with self._condition:
|
||||
if backend_id not in self.backends:
|
||||
raise KeyError(f"Unknown backend: {backend_id}")
|
||||
if self.active_backend_id == backend_id:
|
||||
return
|
||||
self.active_backend_id = backend_id
|
||||
self._condition.notify_all()
|
||||
|
||||
def set_output_enabled(self, enabled: bool) -> None:
|
||||
with self._condition:
|
||||
enabled = bool(enabled)
|
||||
if self.output_enabled == enabled:
|
||||
return
|
||||
self.output_enabled = enabled
|
||||
if enabled:
|
||||
self._ensure_worker_locked()
|
||||
self._controller_metrics = ControllerMetrics(
|
||||
source="Controller-side FPS polling disabled during live output for stability.",
|
||||
)
|
||||
self._condition.notify_all()
|
||||
|
||||
def set_target_fps(self, value: float) -> None:
|
||||
with self._condition:
|
||||
target_fps = self._clamp_target_fps(value)
|
||||
if abs(target_fps - self._target_fps) < 1e-9:
|
||||
return
|
||||
self._target_fps = target_fps
|
||||
self._condition.notify_all()
|
||||
|
||||
def target_fps(self) -> float:
|
||||
with self._lock:
|
||||
return self._target_fps
|
||||
|
||||
def active_backend(self) -> OutputBackend:
|
||||
with self._lock:
|
||||
return self.backends[self.active_backend_id]
|
||||
|
||||
def update_config(self, config: InfinityMirrorConfig) -> None:
|
||||
config_snapshot = config.clone()
|
||||
with self._condition:
|
||||
self._config_snapshot = config_snapshot
|
||||
self._config_source_id = id(config)
|
||||
self._condition.notify_all()
|
||||
|
||||
def submit_frame(self, frame: PreviewFrame) -> OutputResult:
|
||||
with self._condition:
|
||||
if self.output_enabled and self._latest_frame_version > self._sent_frame_version:
|
||||
self._stale_frame_drops += 1
|
||||
self._latest_frame = frame
|
||||
self._latest_frame_version += 1
|
||||
self._frames_submitted += 1
|
||||
if self.output_enabled:
|
||||
self._ensure_worker_locked()
|
||||
self._condition.notify_all()
|
||||
message = "Frame submitted to output worker." if self.output_enabled else "Hardware output disabled."
|
||||
return OutputResult(ok=True, message=message)
|
||||
|
||||
def push_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
|
||||
with self._lock:
|
||||
config_source_id = self._config_source_id
|
||||
if config_source_id != id(config):
|
||||
self.update_config(config)
|
||||
return self.submit_frame(frame)
|
||||
|
||||
def drain_status_messages(self) -> list[str]:
|
||||
with self._lock:
|
||||
messages = list(self._pending_messages)
|
||||
self._pending_messages.clear()
|
||||
return messages
|
||||
|
||||
def diagnostics_snapshot(self) -> OutputDiagnostics:
|
||||
with self._lock:
|
||||
backend = self.backends[self.active_backend_id]
|
||||
worker_running = self.output_enabled and self._worker_thread is not None and self._worker_thread.is_alive()
|
||||
return OutputDiagnostics(
|
||||
backend_id=self.active_backend_id,
|
||||
backend_name=backend.display_name,
|
||||
output_enabled=self.output_enabled,
|
||||
worker_running=worker_running,
|
||||
target_fps=self._target_fps,
|
||||
send_fps=self._send_fps,
|
||||
last_send_time_ms=self._last_send_duration_s * 1000.0,
|
||||
frames_submitted=self._frames_submitted,
|
||||
frames_sent=self._frames_sent,
|
||||
stale_frame_drops=self._stale_frame_drops,
|
||||
send_failures=self._send_failures,
|
||||
packets_last_frame=self._packets_last_frame,
|
||||
devices_last_frame=self._devices_last_frame,
|
||||
packets_sent_total=self._packets_sent_total,
|
||||
last_message=self._last_result_message,
|
||||
send_budget_misses=self._send_budget_misses,
|
||||
last_schedule_slip_ms=self._last_schedule_slip_s * 1000.0,
|
||||
controller_fps=self._controller_metrics.fps,
|
||||
controller_live_devices=self._controller_metrics.live_devices,
|
||||
controller_sampled_devices=self._controller_metrics.sampled_devices,
|
||||
controller_total_devices=self._controller_metrics.total_devices,
|
||||
controller_source=self._controller_metrics.source,
|
||||
)
|
||||
|
||||
def shutdown(self) -> None:
|
||||
thread: threading.Thread | None = None
|
||||
telemetry_thread: threading.Thread | None = None
|
||||
with self._condition:
|
||||
self.output_enabled = False
|
||||
self._worker_stop_requested = True
|
||||
self._telemetry_stop_requested = True
|
||||
thread = self._worker_thread
|
||||
telemetry_thread = self._telemetry_thread
|
||||
self._condition.notify_all()
|
||||
if thread is not None:
|
||||
thread.join(timeout=1.0)
|
||||
if telemetry_thread is not None:
|
||||
telemetry_thread.join(timeout=1.0)
|
||||
with self._condition:
|
||||
if self._worker_thread is thread and thread is not None and not thread.is_alive():
|
||||
self._worker_thread = None
|
||||
if self._telemetry_thread is telemetry_thread and telemetry_thread is not None and not telemetry_thread.is_alive():
|
||||
self._telemetry_thread = None
|
||||
|
||||
def _ensure_worker_locked(self) -> None:
|
||||
if self._worker_thread is not None and self._worker_thread.is_alive():
|
||||
return
|
||||
self._worker_stop_requested = False
|
||||
self._worker_thread = threading.Thread(
|
||||
target=self._worker_loop,
|
||||
name="InfinityMirrorOutputWorker",
|
||||
daemon=True,
|
||||
)
|
||||
self._worker_thread.start()
|
||||
|
||||
def _ensure_telemetry_locked(self) -> None:
|
||||
if self._telemetry_thread is not None and self._telemetry_thread.is_alive():
|
||||
return
|
||||
self._telemetry_stop_requested = False
|
||||
self._telemetry_thread = threading.Thread(
|
||||
target=self._telemetry_loop,
|
||||
name="InfinityMirrorTelemetryWorker",
|
||||
daemon=True,
|
||||
)
|
||||
self._telemetry_thread.start()
|
||||
|
||||
def _worker_loop(self) -> None:
|
||||
current_backend_id: str | None = None
|
||||
current_backend: OutputBackend | None = None
|
||||
next_send_at = time.perf_counter()
|
||||
|
||||
try:
|
||||
while True:
|
||||
action = "wait"
|
||||
desired_backend_id = ""
|
||||
desired_backend: OutputBackend | None = None
|
||||
config: InfinityMirrorConfig | None = None
|
||||
frame: PreviewFrame | None = None
|
||||
frame_version = 0
|
||||
interval_s = 1.0 / DEFAULT_OUTPUT_FPS
|
||||
scheduled_send_at = next_send_at
|
||||
|
||||
with self._condition:
|
||||
while True:
|
||||
if self._worker_stop_requested:
|
||||
return
|
||||
|
||||
desired_backend_id = self.active_backend_id
|
||||
desired_backend = self.backends[desired_backend_id]
|
||||
interval_s = 1.0 / self._target_fps
|
||||
|
||||
if not self.output_enabled:
|
||||
if current_backend is not None:
|
||||
action = "disable_backend"
|
||||
break
|
||||
self._condition.wait()
|
||||
next_send_at = time.perf_counter()
|
||||
continue
|
||||
|
||||
if current_backend_id != desired_backend_id or current_backend is None:
|
||||
action = "switch_backend"
|
||||
break
|
||||
|
||||
config = self._config_snapshot
|
||||
frame = self._latest_frame
|
||||
frame_version = self._latest_frame_version
|
||||
if config is None or frame is None:
|
||||
self._condition.wait()
|
||||
next_send_at = time.perf_counter()
|
||||
continue
|
||||
|
||||
now = time.perf_counter()
|
||||
wait_timeout = next_send_at - now
|
||||
if wait_timeout > 0.0:
|
||||
self._condition.wait(timeout=wait_timeout)
|
||||
continue
|
||||
|
||||
scheduled_send_at = next_send_at
|
||||
action = "send_frame"
|
||||
break
|
||||
|
||||
if action == "disable_backend":
|
||||
current_backend.stop()
|
||||
current_backend = None
|
||||
current_backend_id = None
|
||||
next_send_at = time.perf_counter()
|
||||
continue
|
||||
|
||||
if action == "switch_backend":
|
||||
if current_backend is not None:
|
||||
current_backend.stop()
|
||||
current_backend = desired_backend
|
||||
current_backend_id = desired_backend_id
|
||||
try:
|
||||
current_backend.start()
|
||||
except OSError as exc:
|
||||
self._queue_status_message(f"{current_backend.display_name} start failed: {exc}")
|
||||
current_backend = None
|
||||
current_backend_id = None
|
||||
next_send_at = time.perf_counter()
|
||||
continue
|
||||
|
||||
if action != "send_frame" or current_backend is None or config is None or frame is None:
|
||||
continue
|
||||
|
||||
send_started_at = time.perf_counter()
|
||||
result = current_backend.send_frame(config, frame)
|
||||
send_finished_at = time.perf_counter()
|
||||
send_duration_s = send_finished_at - send_started_at
|
||||
schedule_slip_s = max(0.0, send_started_at - scheduled_send_at)
|
||||
missed_budget = send_finished_at > (scheduled_send_at + interval_s)
|
||||
|
||||
with self._condition:
|
||||
self._last_send_duration_s = send_duration_s
|
||||
self._last_schedule_slip_s = schedule_slip_s
|
||||
if missed_budget:
|
||||
self._send_budget_misses += 1
|
||||
self._frames_sent += 1
|
||||
self._sent_frame_version = max(self._sent_frame_version, frame_version)
|
||||
self._packets_last_frame = result.packets_sent
|
||||
self._devices_last_frame = result.device_count
|
||||
self._packets_sent_total += result.packets_sent
|
||||
self._last_result_message = result.message
|
||||
if not result.ok and result.message:
|
||||
self._send_failures += 1
|
||||
self._queue_status_message(result.message)
|
||||
self._record_send_fps(send_finished_at)
|
||||
|
||||
# Keep a stable cadence anchored to the worker clock instead of adding
|
||||
# a full extra interval after every send. If a send overruns, jump to
|
||||
# "now" and continue with the freshest frame rather than compounding lag.
|
||||
next_send_at = max(next_send_at + interval_s, send_finished_at)
|
||||
finally:
|
||||
if current_backend is not None:
|
||||
try:
|
||||
current_backend.stop()
|
||||
finally:
|
||||
with self._condition:
|
||||
if self._worker_thread is threading.current_thread():
|
||||
self._worker_thread = None
|
||||
else:
|
||||
with self._condition:
|
||||
if self._worker_thread is threading.current_thread():
|
||||
self._worker_thread = None
|
||||
|
||||
def _telemetry_loop(self) -> None:
|
||||
next_poll_at = time.perf_counter()
|
||||
try:
|
||||
while True:
|
||||
backend: OutputBackend | None = None
|
||||
config: InfinityMirrorConfig | None = None
|
||||
with self._condition:
|
||||
while True:
|
||||
if self._telemetry_stop_requested:
|
||||
return
|
||||
if not self.output_enabled:
|
||||
self._controller_metrics = ControllerMetrics()
|
||||
self._condition.wait()
|
||||
next_poll_at = time.perf_counter()
|
||||
continue
|
||||
config = self._config_snapshot
|
||||
backend = self.backends[self.active_backend_id]
|
||||
if config is None:
|
||||
self._condition.wait(timeout=0.2)
|
||||
next_poll_at = time.perf_counter()
|
||||
continue
|
||||
now = time.perf_counter()
|
||||
wait_timeout = next_poll_at - now
|
||||
if wait_timeout > 0.0:
|
||||
self._condition.wait(timeout=wait_timeout)
|
||||
continue
|
||||
break
|
||||
|
||||
metrics = backend.controller_metrics(config) if backend is not None and config is not None else None
|
||||
with self._condition:
|
||||
self._controller_metrics = metrics if metrics is not None else ControllerMetrics()
|
||||
next_poll_at = time.perf_counter() + CONTROLLER_TELEMETRY_INTERVAL_S
|
||||
finally:
|
||||
with self._condition:
|
||||
if self._telemetry_thread is threading.current_thread():
|
||||
self._telemetry_thread = None
|
||||
|
||||
def _queue_status_message(self, message: str) -> None:
|
||||
if not message or message == self._last_queued_message:
|
||||
return
|
||||
self._pending_messages.append(message)
|
||||
self._last_queued_message = message
|
||||
|
||||
def _record_send_fps(self, timestamp: float) -> None:
|
||||
if self._send_window_started_at <= 0.0:
|
||||
self._send_window_started_at = timestamp
|
||||
self._send_window_count = 1
|
||||
self._send_fps = 0.0
|
||||
return
|
||||
|
||||
self._send_window_count += 1
|
||||
elapsed = timestamp - self._send_window_started_at
|
||||
if elapsed >= 0.5:
|
||||
self._send_fps = self._send_window_count / elapsed
|
||||
self._send_window_started_at = timestamp
|
||||
self._send_window_count = 0
|
||||
|
||||
@staticmethod
|
||||
def _clamp_target_fps(value: float) -> float:
|
||||
return max(MIN_OUTPUT_FPS, min(MAX_OUTPUT_FPS, float(value)))
|
||||
@@ -0,0 +1,15 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.core.types import PreviewFrame
|
||||
|
||||
from .base import OutputBackend, OutputResult
|
||||
|
||||
|
||||
class PreviewOutputBackend(OutputBackend):
|
||||
backend_id = "preview"
|
||||
display_name = "Preview Only"
|
||||
supports_live_output = False
|
||||
|
||||
def send_frame(self, config: InfinityMirrorConfig, frame: PreviewFrame) -> OutputResult:
|
||||
return OutputResult(ok=True, message="Preview-only mode active.", packets_sent=0, device_count=0)
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Pattern registry and built-in pattern implementations."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,276 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from abc import ABC, abstractmethod
|
||||
from dataclasses import dataclass
|
||||
from typing import Iterable
|
||||
|
||||
from app.config.models import InfinityMirrorConfig, TileConfig
|
||||
from app.core.types import PatternParameters, TilePatternSample, clamp
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ParameterSpec:
|
||||
key: str
|
||||
label: str
|
||||
kind: str
|
||||
minimum: float = 0.0
|
||||
maximum: float = 1.0
|
||||
step: float = 0.01
|
||||
reset_value: float | None = None
|
||||
options: tuple[tuple[str, str], ...] = ()
|
||||
tooltip: str = ""
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PatternDescriptor:
|
||||
pattern_id: str
|
||||
display_name: str
|
||||
description: str
|
||||
supported_parameters: tuple[str, ...]
|
||||
accent_hex: str = "#4D7CFF"
|
||||
temporal_profile: str = "smooth"
|
||||
|
||||
|
||||
@dataclass
|
||||
class PatternContext:
|
||||
config: InfinityMirrorConfig
|
||||
params: PatternParameters
|
||||
time_s: float
|
||||
tempo_bpm: float = 60.0
|
||||
tempo_phase: float = 0.0
|
||||
|
||||
@property
|
||||
def rows(self) -> int:
|
||||
return self.config.logical_display.rows
|
||||
|
||||
@property
|
||||
def cols(self) -> int:
|
||||
return self.config.logical_display.cols
|
||||
|
||||
def sorted_tiles(self) -> list[TileConfig]:
|
||||
return self.config.sorted_tiles()
|
||||
|
||||
@property
|
||||
def tempo_hz(self) -> float:
|
||||
return max(0.05, float(self.tempo_bpm) / 60.0)
|
||||
|
||||
@property
|
||||
def tempo_multiplier(self) -> float:
|
||||
return clamp(float(self.params.tempo_multiplier), 0.25, 8.0)
|
||||
|
||||
@property
|
||||
def pattern_tempo_hz(self) -> float:
|
||||
return self.tempo_hz * self.tempo_multiplier
|
||||
|
||||
@property
|
||||
def pattern_tempo_phase(self) -> float:
|
||||
return self.tempo_phase * self.tempo_multiplier
|
||||
|
||||
|
||||
class BasePattern(ABC):
|
||||
descriptor: PatternDescriptor
|
||||
|
||||
@abstractmethod
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
raise NotImplementedError
|
||||
|
||||
|
||||
class PatternRegistry:
|
||||
def __init__(self, patterns: Iterable[BasePattern]) -> None:
|
||||
self._patterns = {pattern.descriptor.pattern_id: pattern for pattern in patterns}
|
||||
|
||||
def get(self, pattern_id: str) -> BasePattern:
|
||||
return self._patterns[pattern_id]
|
||||
|
||||
def descriptors(self) -> list[PatternDescriptor]:
|
||||
return [self._patterns[key].descriptor for key in sorted(self._patterns)]
|
||||
|
||||
def ids(self) -> list[str]:
|
||||
return list(sorted(self._patterns))
|
||||
|
||||
|
||||
COMMON_PARAMETER_SPECS: dict[str, ParameterSpec] = {
|
||||
"brightness": ParameterSpec("brightness", "Brightness", "slider", 0.0, 2.0, 0.01, reset_value=1.0, tooltip="Pattern output level."),
|
||||
"fade": ParameterSpec("fade", "Smoothing", "slider", 0.0, 1.0, 0.01, reset_value=0.0, tooltip="Higher values create softer transitions."),
|
||||
"tempo_multiplier": ParameterSpec(
|
||||
"tempo_multiplier",
|
||||
"Tempo Multiplier",
|
||||
"slider",
|
||||
0.25,
|
||||
8.0,
|
||||
0.05,
|
||||
reset_value=1.0,
|
||||
tooltip="Scales this pattern relative to the global BPM.",
|
||||
),
|
||||
"direction": ParameterSpec(
|
||||
"direction",
|
||||
"Direction",
|
||||
"combo",
|
||||
options=(
|
||||
("left_to_right", "Left to Right"),
|
||||
("right_to_left", "Right to Left"),
|
||||
("top_to_bottom", "Top to Bottom"),
|
||||
("bottom_to_top", "Bottom to Top"),
|
||||
("outward", "Outward"),
|
||||
("inward", "Inward"),
|
||||
),
|
||||
tooltip="Primary motion direction.",
|
||||
),
|
||||
"checker_mode": ParameterSpec(
|
||||
"checker_mode",
|
||||
"Checker Mode",
|
||||
"combo",
|
||||
options=(
|
||||
("classic", "Classic"),
|
||||
("diagonal", "Diagonal Split"),
|
||||
("checkerd", "Checkerd"),
|
||||
),
|
||||
tooltip="Classic checker, diagonal half-pixels, or diagonal flip animation.",
|
||||
),
|
||||
"scan_style": ParameterSpec(
|
||||
"scan_style",
|
||||
"Scan Style",
|
||||
"combo",
|
||||
options=(
|
||||
("line", "Line"),
|
||||
("bands", "Bands"),
|
||||
),
|
||||
tooltip="Single moving scan band or repeating band pattern.",
|
||||
),
|
||||
"angle": ParameterSpec(
|
||||
"angle",
|
||||
"Angle",
|
||||
"angle",
|
||||
minimum=0.0,
|
||||
maximum=315.0,
|
||||
step=45.0,
|
||||
tooltip="Scan direction in 45 degree steps.",
|
||||
),
|
||||
"on_width": ParameterSpec(
|
||||
"on_width",
|
||||
"On Width",
|
||||
"slider",
|
||||
0.1,
|
||||
2.0,
|
||||
0.05,
|
||||
tooltip="Length of the active scan window.",
|
||||
),
|
||||
"off_width": ParameterSpec(
|
||||
"off_width",
|
||||
"Off Width",
|
||||
"slider",
|
||||
0.1,
|
||||
2.0,
|
||||
0.05,
|
||||
tooltip="Gap between active scan windows.",
|
||||
),
|
||||
"band_thickness": ParameterSpec(
|
||||
"band_thickness",
|
||||
"Band Thickness",
|
||||
"slider",
|
||||
0.1,
|
||||
2.0,
|
||||
0.05,
|
||||
tooltip="Visible thickness of the lit band inside the active window.",
|
||||
),
|
||||
"flip_horizontal": ParameterSpec(
|
||||
"flip_horizontal",
|
||||
"Flip Horizontal",
|
||||
"checkbox",
|
||||
tooltip="Mirror scan evaluation left-to-right for installation alignment.",
|
||||
),
|
||||
"flip_vertical": ParameterSpec(
|
||||
"flip_vertical",
|
||||
"Flip Vertical",
|
||||
"checkbox",
|
||||
tooltip="Mirror scan evaluation top-to-bottom for installation alignment.",
|
||||
),
|
||||
"strobe_mode": ParameterSpec(
|
||||
"strobe_mode",
|
||||
"Strobe Mode",
|
||||
"combo",
|
||||
options=(
|
||||
("global", "Global"),
|
||||
("random_pixels", "Random Pixels"),
|
||||
("random_leds", "Random LEDs"),
|
||||
),
|
||||
tooltip="Whole-wall strobe, grouped random pixel blocks, or fully shuffled per-LED timing.",
|
||||
),
|
||||
"stopwatch_mode": ParameterSpec(
|
||||
"stopwatch_mode",
|
||||
"Stopwatch Mode",
|
||||
"combo",
|
||||
options=(
|
||||
("sync", "Sync"),
|
||||
("random", "Random"),
|
||||
),
|
||||
tooltip="Run all tiles together or with deterministic random offsets.",
|
||||
),
|
||||
"color_mode": ParameterSpec(
|
||||
"color_mode",
|
||||
"Color Mode",
|
||||
"combo",
|
||||
options=(
|
||||
("dual", "Dual"),
|
||||
("palette", "Palette"),
|
||||
("mono", "Mono"),
|
||||
("complementary", "Complementary"),
|
||||
("random_colors", "Random Colors"),
|
||||
("custom_random", "Custom Random"),
|
||||
),
|
||||
tooltip="How colors are chosen for the pattern.",
|
||||
),
|
||||
"primary_color": ParameterSpec("primary_color", "Primary Color", "color", tooltip="Main color."),
|
||||
"secondary_color": ParameterSpec("secondary_color", "Secondary Color", "color", tooltip="Secondary color."),
|
||||
"palette": ParameterSpec("palette", "Palette", "combo", tooltip="Palette for palette-driven patterns."),
|
||||
"symmetry": ParameterSpec(
|
||||
"symmetry",
|
||||
"Mirror",
|
||||
"combo",
|
||||
options=(("none", "None"), ("horizontal", "Horizontal"), ("vertical", "Vertical"), ("both", "Both")),
|
||||
tooltip="Mirrors pattern coordinates around the center.",
|
||||
),
|
||||
"center_pulse_mode": ParameterSpec(
|
||||
"center_pulse_mode",
|
||||
"Pulse Mode",
|
||||
"combo",
|
||||
options=(
|
||||
("expand", "Expand"),
|
||||
("reverse", "Reverse"),
|
||||
("outline", "Outline"),
|
||||
("outline_reverse", "Outline Reverse"),
|
||||
),
|
||||
tooltip="Expand from the center, run inward, or use only the rectangular outline rings.",
|
||||
),
|
||||
"block_size": ParameterSpec("block_size", "Block Size", "slider", 0.1, 6.0, 0.1, tooltip="Width of active bands."),
|
||||
"pixel_group_size": ParameterSpec(
|
||||
"pixel_group_size",
|
||||
"Pixel Group",
|
||||
"slider",
|
||||
1.0,
|
||||
5.0,
|
||||
1.0,
|
||||
reset_value=1.0,
|
||||
tooltip="Treat several adjacent LEDs as one strobe pixel.",
|
||||
),
|
||||
"strobe_duty_cycle": ParameterSpec(
|
||||
"strobe_duty_cycle",
|
||||
"Duty / Density",
|
||||
"slider",
|
||||
0.005,
|
||||
0.98,
|
||||
0.005,
|
||||
reset_value=0.5,
|
||||
tooltip="Controls strobe on-time or sparkle fill density depending on the pattern.",
|
||||
),
|
||||
"randomness": ParameterSpec(
|
||||
"randomness",
|
||||
"Randomness",
|
||||
"slider",
|
||||
0.0,
|
||||
1.5,
|
||||
0.01,
|
||||
reset_value=0.35,
|
||||
tooltip="Controls variation in patterns that intentionally use randomness.",
|
||||
),
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from ..base import BasePattern
|
||||
from .fills import (
|
||||
BreathingPattern,
|
||||
CenterPulsePattern,
|
||||
CheckerPattern,
|
||||
ColumnGradientPattern,
|
||||
RowGradientPattern,
|
||||
SolidPattern,
|
||||
SparklePattern,
|
||||
)
|
||||
from .motion import (
|
||||
ArrowPattern,
|
||||
SawPattern,
|
||||
ScanDualPattern,
|
||||
ScanPattern,
|
||||
SweepPattern,
|
||||
TwoDotsPattern,
|
||||
WaveLinePattern,
|
||||
)
|
||||
from .special import SnakePattern, StopwatchPattern, StrobePattern
|
||||
|
||||
|
||||
def built_in_patterns() -> list[BasePattern]:
|
||||
return [
|
||||
ArrowPattern(),
|
||||
BreathingPattern(),
|
||||
CenterPulsePattern(),
|
||||
CheckerPattern(),
|
||||
ColumnGradientPattern(),
|
||||
RowGradientPattern(),
|
||||
SawPattern(),
|
||||
ScanPattern(),
|
||||
ScanDualPattern(),
|
||||
SnakePattern(),
|
||||
SolidPattern(),
|
||||
SparklePattern(),
|
||||
StopwatchPattern(),
|
||||
StrobePattern(),
|
||||
SweepPattern(),
|
||||
TwoDotsPattern(),
|
||||
WaveLinePattern(),
|
||||
]
|
||||
|
||||
|
||||
__all__ = [
|
||||
"ArrowPattern",
|
||||
"BreathingPattern",
|
||||
"CenterPulsePattern",
|
||||
"CheckerPattern",
|
||||
"ColumnGradientPattern",
|
||||
"RowGradientPattern",
|
||||
"SawPattern",
|
||||
"ScanDualPattern",
|
||||
"ScanPattern",
|
||||
"SnakePattern",
|
||||
"SolidPattern",
|
||||
"SparklePattern",
|
||||
"StopwatchPattern",
|
||||
"StrobePattern",
|
||||
"SweepPattern",
|
||||
"TwoDotsPattern",
|
||||
"WaveLinePattern",
|
||||
"built_in_patterns",
|
||||
]
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,212 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.core.colors import (
|
||||
brighten,
|
||||
choose_pair,
|
||||
custom_random_color_choices,
|
||||
label_contrast,
|
||||
sample_random_effect_color,
|
||||
)
|
||||
from app.core.types import RGBColor, TilePatternSample, clamp
|
||||
|
||||
from ..base import PatternContext
|
||||
|
||||
|
||||
def _mirror_position(position: float, enabled: bool) -> float:
|
||||
return min(position, 1.0 - position) * 2.0 if enabled else position
|
||||
|
||||
|
||||
def _directional_amount(context: PatternContext, row_index: int, col_index: int) -> float:
|
||||
rows = max(1, context.rows - 1)
|
||||
cols = max(1, context.cols - 1)
|
||||
row_position = row_index / rows
|
||||
col_position = col_index / cols
|
||||
direction = context.params.direction
|
||||
|
||||
if direction == "right_to_left":
|
||||
return 1.0 - col_position
|
||||
if direction == "top_to_bottom":
|
||||
return row_position
|
||||
if direction == "bottom_to_top":
|
||||
return 1.0 - row_position
|
||||
if direction == "outward":
|
||||
return abs(col_position - 0.5) * 2.0
|
||||
if direction == "inward":
|
||||
return 1.0 - abs(col_position - 0.5) * 2.0
|
||||
return col_position
|
||||
|
||||
|
||||
def _random_color_pair(context: PatternContext, seed: float) -> tuple[RGBColor, RGBColor]:
|
||||
primary = _random_vivid_color(seed)
|
||||
secondary = primary.scaled(0.08)
|
||||
return primary, secondary
|
||||
|
||||
|
||||
def _custom_random_color_pair(context: PatternContext, seed: float) -> tuple[RGBColor, RGBColor]:
|
||||
choices = custom_random_color_choices(context.params.primary_color, context.params.secondary_color)
|
||||
primary = choices[int(_temporal_noise(seed) * len(choices)) % len(choices)]
|
||||
secondary = primary.scaled(0.08)
|
||||
return primary, secondary
|
||||
|
||||
|
||||
def _spatial_color_seed(amount: float, row_index: int, col_index: int) -> float:
|
||||
return (
|
||||
amount * 7.31
|
||||
+ (row_index + 1) * 0.613
|
||||
+ (col_index + 1) * 1.137
|
||||
+ (row_index + 1) * (col_index + 1) * 0.071
|
||||
)
|
||||
|
||||
|
||||
def _sample_for_tile(
|
||||
context: PatternContext,
|
||||
amount: float,
|
||||
row_index: int | None = None,
|
||||
col_index: int | None = None,
|
||||
) -> tuple[RGBColor, RGBColor]:
|
||||
if context.params.color_mode == "random_colors":
|
||||
return _random_color_pair(context, _spatial_color_seed(amount, row_index or 0, col_index or 0))
|
||||
if context.params.color_mode == "custom_random":
|
||||
return _custom_random_color_pair(context, _spatial_color_seed(amount, row_index or 0, col_index or 0))
|
||||
return choose_pair(
|
||||
context.params.color_mode,
|
||||
context.params.primary_color,
|
||||
context.params.secondary_color,
|
||||
context.params.palette,
|
||||
amount,
|
||||
)
|
||||
|
||||
|
||||
def _sample_for_cycle(context: PatternContext, amount: float, seed: float) -> tuple[RGBColor, RGBColor]:
|
||||
if context.params.color_mode == "random_colors":
|
||||
return _random_color_pair(context, seed + amount * 3.1)
|
||||
if context.params.color_mode == "custom_random":
|
||||
return _custom_random_color_pair(context, seed + amount * 3.1)
|
||||
return _sample_for_tile(context, amount)
|
||||
|
||||
|
||||
def _blend_colors(primary: RGBColor, secondary: RGBColor, amount: float, floor: float = 0.0) -> RGBColor:
|
||||
floor = clamp(floor)
|
||||
return secondary.mix(primary, floor + (1.0 - floor) * clamp(amount))
|
||||
|
||||
|
||||
def _tile_sample(fill: RGBColor, accent: RGBColor, intensity: float = 1.0, boost: float = 0.1) -> TilePatternSample:
|
||||
intensity = clamp(intensity)
|
||||
if intensity <= 0.0 and fill.to_8bit_tuple() == (0, 0, 0):
|
||||
glow = RGBColor.black()
|
||||
rim = RGBColor.black()
|
||||
else:
|
||||
glow = brighten(fill, boost)
|
||||
rim = fill.mix(accent, 0.24)
|
||||
return TilePatternSample(
|
||||
fill_color=fill,
|
||||
glow_color=glow,
|
||||
rim_color=rim,
|
||||
label_color=label_contrast(fill),
|
||||
intensity=intensity,
|
||||
)
|
||||
|
||||
|
||||
def _diagonal_split_sample(
|
||||
color_a: RGBColor,
|
||||
color_b: RGBColor,
|
||||
accent: RGBColor,
|
||||
orientation: str,
|
||||
intensity: float = 1.0,
|
||||
boost: float = 0.1,
|
||||
) -> TilePatternSample:
|
||||
sample = _tile_sample(color_a.mix(color_b, 0.5), accent, intensity=intensity, boost=boost)
|
||||
sample.metadata["diagonal_split"] = {
|
||||
"orientation": orientation,
|
||||
"color_a": color_a,
|
||||
"color_b": color_b,
|
||||
}
|
||||
return sample
|
||||
|
||||
|
||||
def _with_led_pixels(sample: TilePatternSample, led_pixels: dict[str, list[RGBColor]]) -> TilePatternSample:
|
||||
sample.metadata["led_pixels"] = led_pixels
|
||||
return sample
|
||||
|
||||
|
||||
def _noise(value: float) -> float:
|
||||
return value - math.floor(value)
|
||||
|
||||
|
||||
def _temporal_noise(seed: float) -> float:
|
||||
return _noise(math.sin(seed * 12.9898) * 43758.5453)
|
||||
|
||||
|
||||
def _random_vivid_color(seed: float) -> RGBColor:
|
||||
return sample_random_effect_color(_temporal_noise(seed))
|
||||
|
||||
|
||||
def _axis_data(context: PatternContext, row_index: int, col_index: int) -> tuple[float, int, bool]:
|
||||
vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"}
|
||||
position = float(row_index if vertical else col_index)
|
||||
count = context.rows if vertical else context.cols
|
||||
return position, max(1, count), vertical
|
||||
|
||||
|
||||
_SCAN_VECTORS: dict[int, tuple[int, int]] = {
|
||||
0: (1, 0),
|
||||
45: (1, 1),
|
||||
90: (0, 1),
|
||||
135: (-1, 1),
|
||||
180: (-1, 0),
|
||||
225: (-1, -1),
|
||||
270: (0, -1),
|
||||
315: (1, -1),
|
||||
}
|
||||
|
||||
|
||||
def _scan_vector(angle: float) -> tuple[int, int]:
|
||||
return _SCAN_VECTORS[int(angle) % 360]
|
||||
|
||||
|
||||
def _scan_point(context: PatternContext, row_index: int, col_index: int, local_x: float, local_y: float) -> tuple[float, float]:
|
||||
return col_index + local_x, row_index + local_y
|
||||
|
||||
|
||||
def _scan_projection(
|
||||
context: PatternContext,
|
||||
row_index: int,
|
||||
col_index: int,
|
||||
local_x: float,
|
||||
local_y: float,
|
||||
vector: tuple[int, int],
|
||||
) -> float:
|
||||
x_pos, y_pos = _scan_point(context, row_index, col_index, local_x, local_y)
|
||||
return x_pos * vector[0] + y_pos * vector[1]
|
||||
|
||||
|
||||
def _scan_bounds(context: PatternContext, vector: tuple[int, int]) -> tuple[float, float]:
|
||||
corners = (
|
||||
(0.0, 0.0),
|
||||
(float(context.cols), 0.0),
|
||||
(0.0, float(context.rows)),
|
||||
(float(context.cols), float(context.rows)),
|
||||
)
|
||||
projections = [x_pos * vector[0] + y_pos * vector[1] for x_pos, y_pos in corners]
|
||||
return min(projections), max(projections)
|
||||
|
||||
|
||||
def _scan_band_amount(
|
||||
progress: float,
|
||||
phase: float,
|
||||
min_progress: float,
|
||||
max_progress: float,
|
||||
on_width: float,
|
||||
off_width: float,
|
||||
scan_style: str,
|
||||
) -> float:
|
||||
if scan_style == "bands":
|
||||
period = max(0.1, on_width + off_width)
|
||||
local = (progress - min_progress - phase) % period
|
||||
return 1.0 if local < on_width else 0.0
|
||||
|
||||
travel = max(0.1, (max_progress - min_progress) + on_width + max(0.0, off_width))
|
||||
band_center = min_progress + (phase % travel)
|
||||
return 1.0 if abs(progress - band_center) <= on_width * 0.5 else 0.0
|
||||
@@ -0,0 +1,264 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.core.colors import ease_in_out_sine, oscillate, sample_palette, smoothstep
|
||||
from app.core.types import RGBColor, TilePatternSample, clamp
|
||||
|
||||
from ..base import BasePattern, PatternContext, PatternDescriptor
|
||||
from .common import (
|
||||
_diagonal_split_sample,
|
||||
_directional_amount,
|
||||
_mirror_position,
|
||||
_sample_for_cycle,
|
||||
_sample_for_tile,
|
||||
_temporal_noise,
|
||||
_tile_sample,
|
||||
_with_led_pixels,
|
||||
)
|
||||
|
||||
|
||||
_SEGMENT_NEIGHBOR_OFFSETS: dict[str, tuple[int, int]] = {
|
||||
"left": (0, -1),
|
||||
"right": (0, 1),
|
||||
"top": (-1, 0),
|
||||
"bottom": (1, 0),
|
||||
}
|
||||
|
||||
|
||||
def _front_position(phase: float, max_distance: int, reverse: bool) -> float:
|
||||
if max_distance <= 0:
|
||||
return 0.0
|
||||
front = (phase * 1.35) % (max_distance + 1.0)
|
||||
return max_distance - front if reverse else front
|
||||
|
||||
|
||||
def _center_distances(context: PatternContext) -> tuple[dict[tuple[int, int], int], int]:
|
||||
center_rows = [context.rows // 2] if context.rows % 2 == 1 else [max(0, context.rows // 2 - 1), context.rows // 2]
|
||||
center_cols = [context.cols // 2] if context.cols % 2 == 1 else [max(0, context.cols // 2 - 1), context.cols // 2]
|
||||
centers = {(row, col) for row in center_rows for col in center_cols}
|
||||
distances = {
|
||||
(tile.row - 1, tile.col - 1): min(abs((tile.row - 1) - center_row) + abs((tile.col - 1) - center_col) for center_row, center_col in centers)
|
||||
for tile in context.sorted_tiles()
|
||||
}
|
||||
return distances, max(distances.values(), default=0)
|
||||
|
||||
|
||||
def _outline_depths(context: PatternContext) -> tuple[dict[tuple[int, int], int], int]:
|
||||
depths = {
|
||||
(tile.row - 1, tile.col - 1): min(tile.row - 1, tile.col - 1, context.rows - tile.row, context.cols - tile.col)
|
||||
for tile in context.sorted_tiles()
|
||||
}
|
||||
return depths, max(depths.values(), default=0)
|
||||
|
||||
|
||||
def _outline_led_pixels(context: PatternContext, tile, depths: dict[tuple[int, int], int], depth: int, color: RGBColor) -> dict[str, list[RGBColor]]:
|
||||
pixels: dict[str, list[RGBColor]] = {}
|
||||
row_index = tile.row - 1
|
||||
col_index = tile.col - 1
|
||||
black = RGBColor.black()
|
||||
for segment in tile.segments:
|
||||
delta = _SEGMENT_NEIGHBOR_OFFSETS.get(segment.side)
|
||||
active = False
|
||||
if delta is not None:
|
||||
neighbor = (row_index + delta[0], col_index + delta[1])
|
||||
active = depths.get(neighbor, -1) < depth
|
||||
pixels[segment.name] = [color if active else black for _ in range(segment.led_count)]
|
||||
return pixels
|
||||
|
||||
|
||||
class SolidPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"solid",
|
||||
"Solid",
|
||||
"Uniform wash across the whole wall.",
|
||||
("brightness", "color_mode", "primary_color", "secondary_color", "palette"),
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
primary, _secondary = _sample_for_tile(context, 0.15)
|
||||
return {tile.tile_id: _tile_sample(primary, primary) for tile in context.sorted_tiles()}
|
||||
|
||||
|
||||
class CheckerPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"checker",
|
||||
"Checkerd",
|
||||
"Alternating dual-color checkerboard.",
|
||||
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "checker_mode"),
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
phase = int(math.floor(context.pattern_tempo_phase))
|
||||
checker_mode = context.params.checker_mode
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
for tile in context.sorted_tiles():
|
||||
row_index = tile.row - 1
|
||||
col_index = tile.col - 1
|
||||
amount = (tile.col - 1) / max(1, context.cols - 1)
|
||||
primary, secondary = _sample_for_tile(context, amount, row_index, col_index)
|
||||
parity = (row_index + col_index) % 2
|
||||
if checker_mode == "diagonal":
|
||||
primary_first = (parity + phase) % 2 == 0
|
||||
orientation = "backslash"
|
||||
color_a = primary if primary_first else secondary
|
||||
color_b = secondary if primary_first else primary
|
||||
result[tile.tile_id] = _diagonal_split_sample(color_a, color_b, primary, orientation, boost=0.06)
|
||||
continue
|
||||
if checker_mode == "checkerd":
|
||||
orientation = "backslash" if phase % 2 == 0 else "slash"
|
||||
color_a = primary if parity == 0 else secondary
|
||||
color_b = secondary if parity == 0 else primary
|
||||
result[tile.tile_id] = _diagonal_split_sample(color_a, color_b, primary, orientation, boost=0.06)
|
||||
continue
|
||||
|
||||
fill = primary if (parity + phase) % 2 == 0 else secondary
|
||||
accent = secondary if fill == primary else primary
|
||||
result[tile.tile_id] = _tile_sample(fill, accent)
|
||||
return result
|
||||
|
||||
|
||||
class RowGradientPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"row_gradient",
|
||||
"Row Gradient",
|
||||
"Vertical blend across the rows.",
|
||||
("brightness", "fade", "direction", "color_mode", "primary_color", "secondary_color", "palette", "symmetry"),
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
for tile in context.sorted_tiles():
|
||||
row_amount = (tile.row - 1) / max(1, context.rows - 1)
|
||||
if context.params.direction == "bottom_to_top":
|
||||
row_amount = 1.0 - row_amount
|
||||
row_amount = _mirror_position(row_amount, context.params.symmetry in {"vertical", "both"})
|
||||
primary, secondary = _sample_for_tile(context, row_amount, tile.row - 1, tile.col - 1)
|
||||
fill = secondary.mix(primary, row_amount)
|
||||
result[tile.tile_id] = _tile_sample(fill, primary)
|
||||
return result
|
||||
|
||||
|
||||
class ColumnGradientPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"column_gradient",
|
||||
"Column Gradient",
|
||||
"Horizontal blend across the columns.",
|
||||
("brightness", "fade", "direction", "color_mode", "primary_color", "secondary_color", "palette", "symmetry"),
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
for tile in context.sorted_tiles():
|
||||
amount = _directional_amount(context, tile.row - 1, tile.col - 1)
|
||||
amount = _mirror_position(amount, context.params.symmetry in {"horizontal", "both"})
|
||||
primary, secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1)
|
||||
fill = secondary.mix(primary, amount)
|
||||
result[tile.tile_id] = _tile_sample(fill, primary)
|
||||
return result
|
||||
|
||||
|
||||
class CenterPulsePattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"center_pulse",
|
||||
"Center Pulse",
|
||||
"Radial waves expanding from the center.",
|
||||
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "center_pulse_mode"),
|
||||
temporal_profile="direct",
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
mode = context.params.center_pulse_mode
|
||||
if mode in {"outline", "outline_reverse"}:
|
||||
return self._render_outline(context, reverse=mode == "outline_reverse")
|
||||
return self._render_fill(context, reverse=mode == "reverse")
|
||||
|
||||
def _render_fill(self, context: PatternContext, *, reverse: bool) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
distances, max_distance = _center_distances(context)
|
||||
front = _front_position(context.pattern_tempo_phase, max_distance, reverse)
|
||||
|
||||
for tile in context.sorted_tiles():
|
||||
tile_distance = float(distances[(tile.row - 1, tile.col - 1)])
|
||||
amount = 1.0 - smoothstep(0.0, 0.7, abs(tile_distance - front))
|
||||
primary, secondary = _sample_for_tile(context, tile_distance / max(1, max_distance), tile.row - 1, tile.col - 1)
|
||||
if context.params.color_mode == "mono":
|
||||
fill = primary.scaled(amount)
|
||||
else:
|
||||
fill = secondary.mix(primary, amount).scaled(amount)
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=amount, boost=0.1)
|
||||
return result
|
||||
|
||||
def _render_outline(self, context: PatternContext, *, reverse: bool) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
depths, max_depth = _outline_depths(context)
|
||||
front = _front_position(context.pattern_tempo_phase, max_depth, reverse)
|
||||
|
||||
for tile in context.sorted_tiles():
|
||||
row_index = tile.row - 1
|
||||
col_index = tile.col - 1
|
||||
depth = depths[(row_index, col_index)]
|
||||
ring_index = max_depth - depth
|
||||
amount = 1.0 - smoothstep(0.0, 0.7, abs(ring_index - front))
|
||||
primary, _secondary = _sample_for_tile(context, ring_index / max(1, max_depth), row_index, col_index)
|
||||
led_color = primary.scaled(amount)
|
||||
led_pixels = _outline_led_pixels(context, tile, depths, depth, led_color)
|
||||
lit_leds = sum(
|
||||
1
|
||||
for segment_pixels in led_pixels.values()
|
||||
for color in segment_pixels
|
||||
if color.to_8bit_tuple() != (0, 0, 0)
|
||||
)
|
||||
total_leds = max(1, sum(len(segment_pixels) for segment_pixels in led_pixels.values()))
|
||||
preview_level = amount * (lit_leds / total_leds)
|
||||
fill = primary.scaled(preview_level * 0.4) if lit_leds else RGBColor.black()
|
||||
sample = _tile_sample(fill, primary, intensity=amount if lit_leds else 0.0, boost=0.08)
|
||||
result[tile.tile_id] = _with_led_pixels(sample, led_pixels)
|
||||
return result
|
||||
|
||||
|
||||
class SparklePattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"sparkle",
|
||||
"Sparkle",
|
||||
"Ambient base layer with random sparkling accents.",
|
||||
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "strobe_duty_cycle"),
|
||||
temporal_profile="direct",
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
density = clamp(context.params.strobe_duty_cycle, 0.02, 0.98)
|
||||
time_bucket = context.pattern_tempo_phase * 7.0
|
||||
bucket_index = math.floor(time_bucket)
|
||||
for tile in context.sorted_tiles():
|
||||
base_amount = (tile.col - 1) / max(1, context.cols - 1)
|
||||
primary, _secondary = _sample_for_cycle(context, base_amount, bucket_index * 29.7 + 4.2)
|
||||
sparkle = _temporal_noise((tile.row * 17.13) + (tile.col * 11.7) + math.floor(time_bucket))
|
||||
burst = smoothstep(1.0 - density, 1.0, sparkle)
|
||||
visible_burst = burst if burst >= 0.05 else 0.0
|
||||
fill = primary.scaled(visible_burst)
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=visible_burst, boost=0.08 + visible_burst * 0.2)
|
||||
return result
|
||||
|
||||
|
||||
class BreathingPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"breathing",
|
||||
"Breathing",
|
||||
"Slow collective inhale and exhale.",
|
||||
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette"),
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
breathe = ease_in_out_sine(oscillate(context.pattern_tempo_phase, 0.25))
|
||||
primary, secondary = _sample_for_tile(context, breathe)
|
||||
base_fill = secondary.mix(primary, breathe)
|
||||
for tile in context.sorted_tiles():
|
||||
fill = base_fill
|
||||
if context.params.color_mode == "palette":
|
||||
palette_color = sample_palette(context.params.palette, (tile.col - 1) / max(1, context.cols - 1))
|
||||
fill = fill.mix(palette_color, 0.18)
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.24 + breathe * 0.76, boost=0.18)
|
||||
return result
|
||||
@@ -0,0 +1,365 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.core.colors import oscillate, smoothstep
|
||||
from app.core.types import RGBColor, TilePatternSample, clamp
|
||||
|
||||
from ..base import BasePattern, PatternContext, PatternDescriptor
|
||||
from .common import (
|
||||
_blend_colors,
|
||||
_diagonal_split_sample,
|
||||
_directional_amount,
|
||||
_mirror_position,
|
||||
_sample_for_tile,
|
||||
_scan_band_amount,
|
||||
_scan_projection,
|
||||
_scan_vector,
|
||||
_tile_sample,
|
||||
)
|
||||
|
||||
|
||||
class WaveLinePattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"wave_line",
|
||||
"Wave Line",
|
||||
"A discrete wave line that can travel left, right, up, or down.",
|
||||
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette"),
|
||||
temporal_profile="direct",
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
rows = max(1, context.rows)
|
||||
cols = max(1, context.cols)
|
||||
triangle_wave = [0, 1, 2, 1]
|
||||
step = int(math.floor(context.pattern_tempo_phase))
|
||||
row_scale = max(0, rows - 1) / 2.0
|
||||
|
||||
if context.params.direction in {"left_to_right", "right_to_left"}:
|
||||
phase = step if context.params.direction == "left_to_right" else -step
|
||||
active_coords = {
|
||||
(int(round(triangle_wave[(col - phase) % len(triangle_wave)] * row_scale)), col)
|
||||
for col in range(cols)
|
||||
}
|
||||
else:
|
||||
col_scale = max(0, cols - 1) / 2.0
|
||||
phase = step if context.params.direction == "top_to_bottom" else -step
|
||||
active_coords = {
|
||||
(row, int(round(triangle_wave[(row - phase) % len(triangle_wave)] * col_scale)))
|
||||
for row in range(rows)
|
||||
}
|
||||
|
||||
for tile in context.sorted_tiles():
|
||||
row_index = tile.row - 1
|
||||
col_index = tile.col - 1
|
||||
amount = col_index / max(1, cols - 1)
|
||||
primary, secondary = _sample_for_tile(context, amount, row_index, col_index)
|
||||
active = (row_index, col_index) in active_coords
|
||||
fill = primary if active else RGBColor.black()
|
||||
result[tile.tile_id] = _tile_sample(fill, primary if active else RGBColor.black(), intensity=1.0 if active else 0.0, boost=0.08 if active else 0.0)
|
||||
return result
|
||||
|
||||
|
||||
class ScanPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"scan",
|
||||
"Scan",
|
||||
"Unified scan renderer for row, column, and diagonal motion.",
|
||||
(
|
||||
"brightness",
|
||||
"fade",
|
||||
"tempo_multiplier",
|
||||
"scan_style",
|
||||
"angle",
|
||||
"on_width",
|
||||
"off_width",
|
||||
"color_mode",
|
||||
"primary_color",
|
||||
"secondary_color",
|
||||
"palette",
|
||||
),
|
||||
temporal_profile="direct",
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
tiles = context.sorted_tiles()
|
||||
angle = int(context.params.angle) % 360
|
||||
vector = _scan_vector(angle)
|
||||
diagonal = abs(vector[0]) == 1 and abs(vector[1]) == 1
|
||||
orientation = "backslash" if vector[0] * vector[1] < 0 else "slash"
|
||||
|
||||
if diagonal and orientation == "backslash":
|
||||
split_points = {"a": (2.0 / 3.0, 1.0 / 3.0), "b": (1.0 / 3.0, 2.0 / 3.0)}
|
||||
else:
|
||||
split_points = {"a": (1.0 / 3.0, 1.0 / 3.0), "b": (2.0 / 3.0, 2.0 / 3.0)}
|
||||
|
||||
lane_points = (split_points["a"], split_points["b"]) if diagonal else ((0.5, 0.5),)
|
||||
lane_progress_values = [
|
||||
_scan_projection(context, tile.row - 1, tile.col - 1, local_x, local_y, vector)
|
||||
for tile in tiles
|
||||
for local_x, local_y in lane_points
|
||||
]
|
||||
min_progress = min(lane_progress_values, default=0.0)
|
||||
max_progress = max(lane_progress_values, default=0.0)
|
||||
phase_scale = max(0.25, min(context.params.on_width, 1.0))
|
||||
phase = context.pattern_tempo_phase * phase_scale
|
||||
|
||||
for tile in tiles:
|
||||
row_index = tile.row - 1
|
||||
col_index = tile.col - 1
|
||||
center_progress = _scan_projection(context, row_index, col_index, 0.5, 0.5, vector)
|
||||
amount = 0.0 if max_progress == min_progress else (center_progress - min_progress) / (max_progress - min_progress)
|
||||
primary, secondary = _sample_for_tile(context, amount, row_index, col_index)
|
||||
off_color = RGBColor.black()
|
||||
|
||||
if diagonal:
|
||||
amount_a = _scan_band_amount(
|
||||
_scan_projection(context, row_index, col_index, split_points["a"][0], split_points["a"][1], vector),
|
||||
phase,
|
||||
min_progress,
|
||||
max_progress,
|
||||
context.params.on_width,
|
||||
context.params.off_width,
|
||||
context.params.scan_style,
|
||||
)
|
||||
amount_b = _scan_band_amount(
|
||||
_scan_projection(context, row_index, col_index, split_points["b"][0], split_points["b"][1], vector),
|
||||
phase,
|
||||
min_progress,
|
||||
max_progress,
|
||||
context.params.on_width,
|
||||
context.params.off_width,
|
||||
context.params.scan_style,
|
||||
)
|
||||
color_a = off_color.mix(primary, amount_a)
|
||||
color_b = off_color.mix(primary, amount_b)
|
||||
result[tile.tile_id] = _diagonal_split_sample(
|
||||
color_a,
|
||||
color_b,
|
||||
primary,
|
||||
orientation,
|
||||
intensity=(amount_a + amount_b) * 0.5,
|
||||
boost=0.08,
|
||||
)
|
||||
continue
|
||||
|
||||
coverage = _scan_band_amount(
|
||||
center_progress,
|
||||
phase,
|
||||
min_progress,
|
||||
max_progress,
|
||||
context.params.on_width,
|
||||
context.params.off_width,
|
||||
context.params.scan_style,
|
||||
)
|
||||
fill = off_color.mix(primary, coverage)
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=coverage, boost=0.08)
|
||||
return result
|
||||
|
||||
|
||||
class ArrowPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"arrow",
|
||||
"Arrow",
|
||||
"Discrete chevrons like > > on the low-res wall.",
|
||||
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "block_size"),
|
||||
temporal_profile="direct",
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
horizontal = context.params.direction not in {"top_to_bottom", "bottom_to_top"}
|
||||
major_count = max(1, context.cols if horizontal else context.rows)
|
||||
minor_count = max(1, context.rows if horizontal else context.cols)
|
||||
middle_minor = (minor_count - 1) / 2.0
|
||||
gap = max(0, int(round(context.params.block_size - 1.0)))
|
||||
span = 3 + gap
|
||||
movement = int(math.floor(context.pattern_tempo_phase))
|
||||
|
||||
def row_band(minor_index: int) -> int:
|
||||
if minor_count <= 1:
|
||||
return 0
|
||||
return 0 if abs(minor_index - middle_minor) <= 0.55 else 1
|
||||
|
||||
def chevron_target(orientation: str, minor_index: int) -> int:
|
||||
band = row_band(minor_index)
|
||||
if orientation in {"right", "down"}:
|
||||
return 1 if band == 0 else 0
|
||||
return 1 if band == 0 else 2
|
||||
|
||||
def cell_active(local_index: int, minor_index: int, orientation: str) -> bool:
|
||||
if local_index >= 3:
|
||||
return False
|
||||
return local_index == chevron_target(orientation, minor_index)
|
||||
|
||||
half_size = max(1, math.ceil(major_count / 2))
|
||||
for tile in context.sorted_tiles():
|
||||
major_index = int(tile.col - 1 if horizontal else tile.row - 1)
|
||||
minor_index = int(tile.row - 1 if horizontal else tile.col - 1)
|
||||
sample_amount = major_index / max(1, major_count - 1)
|
||||
primary, _secondary = _sample_for_tile(context, sample_amount, tile.row - 1, tile.col - 1)
|
||||
|
||||
if context.params.direction in {"outward", "inward"}:
|
||||
left_half = major_index < half_size
|
||||
local_major = major_index if left_half else major_index - half_size
|
||||
if context.params.direction == "outward":
|
||||
orientation = "left" if left_half else "right"
|
||||
local_index = (local_major + movement) % span if left_half else (local_major - movement) % span
|
||||
else:
|
||||
orientation = "right" if left_half else "left"
|
||||
local_index = (local_major - movement) % span if left_half else (local_major + movement) % span
|
||||
else:
|
||||
orientation = "right" if context.params.direction in {"left_to_right", "top_to_bottom"} else "left"
|
||||
local_index = (major_index - movement) % span if orientation == "right" else (major_index + movement) % span
|
||||
|
||||
active = cell_active(local_index, minor_index, orientation)
|
||||
fill = primary if active else RGBColor.black()
|
||||
result[tile.tile_id] = _tile_sample(fill, primary if active else RGBColor.black(), intensity=1.0 if active else 0.0, boost=0.06 if active else 0.0)
|
||||
return result
|
||||
|
||||
|
||||
class ScanDualPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"scan_dual",
|
||||
"Scan Dual",
|
||||
"Mirrored scanner bands inspired by WLED Scan Dual.",
|
||||
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "block_size"),
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"}
|
||||
axis_count = max(1, context.rows if vertical else context.cols)
|
||||
scan = oscillate(context.pattern_tempo_phase, 0.6) * (axis_count - 1)
|
||||
if context.params.direction in {"right_to_left", "bottom_to_top"}:
|
||||
scan = (axis_count - 1) - scan
|
||||
mirror = (axis_count - 1) - scan
|
||||
width = max(0.3, context.params.block_size * 0.28)
|
||||
|
||||
for tile in context.sorted_tiles():
|
||||
pos = float(tile.row - 1 if vertical else tile.col - 1)
|
||||
lead = 1.0 - smoothstep(width, width + 1.0, abs(pos - scan))
|
||||
echo = (1.0 - smoothstep(width, width + 1.0, abs(pos - mirror))) * 0.62
|
||||
sample_amount = pos / max(1, axis_count - 1)
|
||||
primary, secondary = _sample_for_tile(context, sample_amount, tile.row - 1, tile.col - 1)
|
||||
lead_fill = _blend_colors(primary, secondary, lead, floor=0.08)
|
||||
echo_fill = _blend_colors(primary, secondary, echo, floor=0.02)
|
||||
if lead >= echo:
|
||||
fill = lead_fill
|
||||
amount = lead
|
||||
else:
|
||||
fill = echo_fill
|
||||
amount = echo
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.12 + amount * 0.88)
|
||||
return result
|
||||
|
||||
|
||||
class SweepPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"sweep",
|
||||
"Sweep",
|
||||
"Primary and secondary colors wipe through the wall like WLED Sweep.",
|
||||
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette"),
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"}
|
||||
axis_count = max(1, context.rows if vertical else context.cols)
|
||||
softness = 0.26
|
||||
|
||||
if context.params.direction in {"outward", "inward"}:
|
||||
center = (axis_count - 1) / 2.0
|
||||
front = oscillate(context.pattern_tempo_phase, 0.45) * center
|
||||
if context.params.direction == "inward":
|
||||
front = center - front
|
||||
for tile in context.sorted_tiles():
|
||||
pos = float(tile.col - 1 if not vertical else tile.row - 1)
|
||||
distance = abs(pos - center)
|
||||
amount = 1.0 - smoothstep(front, front + softness + 0.6, distance)
|
||||
primary, secondary = _sample_for_tile(context, pos / max(1, axis_count - 1), tile.row - 1, tile.col - 1)
|
||||
fill = secondary.mix(primary, clamp(amount))
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.2 + clamp(amount) * 0.8)
|
||||
return result
|
||||
|
||||
front = oscillate(context.pattern_tempo_phase, 0.45) * (axis_count - 1)
|
||||
if context.params.direction in {"right_to_left", "bottom_to_top"}:
|
||||
front = (axis_count - 1) - front
|
||||
|
||||
for tile in context.sorted_tiles():
|
||||
pos = float(tile.row - 1 if vertical else tile.col - 1)
|
||||
if context.params.direction in {"right_to_left", "bottom_to_top"}:
|
||||
amount = smoothstep(front - softness, front + softness, pos)
|
||||
else:
|
||||
amount = 1.0 - smoothstep(front - softness, front + softness, pos)
|
||||
primary, secondary = _sample_for_tile(context, pos / max(1, axis_count - 1), tile.row - 1, tile.col - 1)
|
||||
fill = secondary.mix(primary, clamp(amount))
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.18 + clamp(amount) * 0.82)
|
||||
return result
|
||||
|
||||
|
||||
class SawPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"saw",
|
||||
"Saw",
|
||||
"A stepped saw-wave sweep inspired by WLED's sharper motion effects.",
|
||||
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "symmetry"),
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
phase = context.pattern_tempo_phase * 0.7
|
||||
quantization = max(context.cols, context.rows)
|
||||
|
||||
for tile in context.sorted_tiles():
|
||||
amount = _directional_amount(context, tile.row - 1, tile.col - 1)
|
||||
if context.params.direction in {"left_to_right", "right_to_left"}:
|
||||
amount = _mirror_position(amount, context.params.symmetry in {"horizontal", "both"})
|
||||
if context.params.direction in {"top_to_bottom", "bottom_to_top"}:
|
||||
amount = _mirror_position(amount, context.params.symmetry in {"vertical", "both"})
|
||||
|
||||
wave = (amount - phase) % 1.0
|
||||
saw = wave / 0.92 if wave < 0.92 else 0.0
|
||||
saw = round(saw * quantization) / max(1, quantization)
|
||||
|
||||
primary, secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1)
|
||||
if context.params.color_mode == "mono":
|
||||
fill = primary.scaled(saw)
|
||||
intensity = saw
|
||||
else:
|
||||
fill = _blend_colors(primary, secondary, saw, floor=0.04)
|
||||
intensity = 0.16 + saw * 0.84
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=intensity)
|
||||
return result
|
||||
|
||||
|
||||
class TwoDotsPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"two_dots",
|
||||
"Two Dots",
|
||||
"Two mirrored highlights travel across the wall, inspired by WLED Two Dots.",
|
||||
("brightness", "fade", "tempo_multiplier", "direction", "color_mode", "primary_color", "secondary_color", "palette", "block_size"),
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
vertical = context.params.direction in {"top_to_bottom", "bottom_to_top"}
|
||||
axis_count = max(1, context.rows if vertical else context.cols)
|
||||
orbit = oscillate(context.pattern_tempo_phase, 0.75) * (axis_count - 1)
|
||||
if context.params.direction in {"right_to_left", "bottom_to_top"}:
|
||||
orbit = (axis_count - 1) - orbit
|
||||
dot_a = orbit
|
||||
dot_b = (axis_count - 1) - orbit
|
||||
width = max(0.25, context.params.block_size * 0.22)
|
||||
|
||||
for tile in context.sorted_tiles():
|
||||
pos = float(tile.row - 1 if vertical else tile.col - 1)
|
||||
pulse_a = 1.0 - smoothstep(width, width + 0.95, abs(pos - dot_a))
|
||||
pulse_b = 1.0 - smoothstep(width, width + 0.95, abs(pos - dot_b))
|
||||
amount = max(pulse_a, pulse_b)
|
||||
primary, secondary = _sample_for_tile(context, pos / max(1, axis_count - 1), tile.row - 1, tile.col - 1)
|
||||
fill = _blend_colors(primary, secondary, amount, floor=0.05)
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.18 + amount * 0.82)
|
||||
return result
|
||||
@@ -0,0 +1,328 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
import random
|
||||
|
||||
from app.core.colors import brighten, sample_palette, smoothstep
|
||||
from app.core.types import RGBColor, TilePatternSample, clamp
|
||||
|
||||
from ..base import BasePattern, PatternContext, PatternDescriptor
|
||||
from .common import (
|
||||
_random_vivid_color,
|
||||
_sample_for_cycle,
|
||||
_sample_for_tile,
|
||||
_temporal_noise,
|
||||
_tile_sample,
|
||||
_with_led_pixels,
|
||||
)
|
||||
|
||||
|
||||
class StrobePattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"strobe",
|
||||
"Strobe",
|
||||
"Fast on/off pulses with duty-cycle control.",
|
||||
(
|
||||
"brightness",
|
||||
"fade",
|
||||
"tempo_multiplier",
|
||||
"strobe_mode",
|
||||
"color_mode",
|
||||
"primary_color",
|
||||
"secondary_color",
|
||||
"palette",
|
||||
"pixel_group_size",
|
||||
"strobe_duty_cycle",
|
||||
),
|
||||
temporal_profile="direct",
|
||||
)
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
if context.params.strobe_mode == "random_pixels":
|
||||
return self._render_random_pixels(context, grouped=True)
|
||||
if context.params.strobe_mode == "random_leds":
|
||||
return self._render_random_pixels(context, grouped=False)
|
||||
|
||||
phase = context.pattern_tempo_phase * 4.0
|
||||
bucket = math.floor(phase)
|
||||
on = phase % 1.0 < context.params.strobe_duty_cycle
|
||||
primary, _secondary = _sample_for_cycle(context, context.time_s * 0.1, bucket * 17.1 + 3.0)
|
||||
if on:
|
||||
return {tile.tile_id: _tile_sample(primary, primary, intensity=1.0) for tile in context.sorted_tiles()}
|
||||
black = RGBColor.black()
|
||||
return {tile.tile_id: _tile_sample(black, black, intensity=0.0, boost=0.0) for tile in context.sorted_tiles()}
|
||||
|
||||
def _render_random_pixels(self, context: PatternContext, *, grouped: bool) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
bucket = math.floor(context.pattern_tempo_phase * 10.0)
|
||||
density = clamp(context.params.strobe_duty_cycle, 0.02, 0.98)
|
||||
pixel_group_size = max(1, min(5, int(round(context.params.pixel_group_size)))) if grouped else 1
|
||||
|
||||
for tile in context.sorted_tiles():
|
||||
amount = (tile.col - 1) / max(1, context.cols - 1)
|
||||
primary, _secondary = _sample_for_cycle(context, amount, bucket * 29.7 + tile.row * 11.7 + tile.col * 17.9)
|
||||
led_pixels: dict[str, list[RGBColor]] = {}
|
||||
lit_leds = 0
|
||||
total_leds = 0
|
||||
|
||||
for segment in tile.segments:
|
||||
segment_pixels: list[RGBColor] = []
|
||||
count = max(1, segment.led_count)
|
||||
for group_start in range(0, count, pixel_group_size):
|
||||
group_index = group_start // pixel_group_size
|
||||
group_end = min(count, group_start + pixel_group_size)
|
||||
seed = bucket * 19.7 + tile.row * 31.3 + tile.col * 17.9 + segment.start_channel * 0.11 + group_index * 1.73
|
||||
active = _temporal_noise(seed) < density
|
||||
if context.params.color_mode == "palette":
|
||||
color = sample_palette(context.params.palette, (amount + group_start / max(1, count - 1) * 0.2) % 1.0)
|
||||
else:
|
||||
color = primary
|
||||
for _index in range(group_start, group_end):
|
||||
segment_pixels.append(color if active else RGBColor.black())
|
||||
lit_leds += 1 if active else 0
|
||||
total_leds += 1
|
||||
led_pixels[segment.name] = segment_pixels
|
||||
|
||||
activity = lit_leds / max(1, total_leds)
|
||||
preview_level = clamp(activity * 8.0, 0.0, 1.0)
|
||||
fill = primary.scaled(preview_level) if lit_leds else RGBColor.black()
|
||||
sample = _tile_sample(fill, primary, intensity=preview_level, boost=0.08)
|
||||
result[tile.tile_id] = _with_led_pixels(sample, led_pixels)
|
||||
return result
|
||||
|
||||
|
||||
class StopwatchPattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"stopwatch",
|
||||
"Stopwatch",
|
||||
"LEDs fill from 1 to N and then clear from N back to 1 on every tile.",
|
||||
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "stopwatch_mode"),
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._last_base_phase_position: float | None = None
|
||||
|
||||
def _tile_led_count(self, tile) -> int:
|
||||
return max(1, sum(segment.led_count for segment in tile.segments))
|
||||
|
||||
def _tile_cycle_color(self, context: PatternContext, tile, cycle_index: int) -> RGBColor:
|
||||
amount = (tile.col - 1 + tile.row - 1) / max(1, context.rows + context.cols - 2)
|
||||
if context.params.color_mode == "random_colors":
|
||||
return _random_vivid_color(cycle_index * 53.1 + tile.row * 11.7 + tile.col * 17.9)
|
||||
primary, _secondary = _sample_for_tile(context, amount, tile.row - 1, tile.col - 1)
|
||||
return primary
|
||||
|
||||
def _crossed_full_count_peak(self, previous_position: float, current_position: float, cycle_length: int, led_count: int) -> bool:
|
||||
if current_position <= previous_position or cycle_length <= 0:
|
||||
return False
|
||||
next_peak = math.floor(previous_position / cycle_length) * cycle_length + led_count
|
||||
if next_peak < previous_position:
|
||||
next_peak += cycle_length
|
||||
return next_peak <= current_position
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
base_phase_position = context.pattern_tempo_phase * 20.0
|
||||
previous_base_phase_position = self._last_base_phase_position
|
||||
use_phase_bridge = (
|
||||
previous_base_phase_position is not None
|
||||
and previous_base_phase_position <= base_phase_position
|
||||
and (base_phase_position - previous_base_phase_position) <= 512.0
|
||||
)
|
||||
self._last_base_phase_position = base_phase_position
|
||||
|
||||
for tile in context.sorted_tiles():
|
||||
led_count = self._tile_led_count(tile)
|
||||
cycle_length = max(1, led_count * 2)
|
||||
offset = 0
|
||||
if context.params.stopwatch_mode == "random":
|
||||
offset = int(_temporal_noise(tile.row * 13.7 + tile.col * 23.9) * cycle_length)
|
||||
|
||||
tile_phase_position = base_phase_position + offset
|
||||
cycle_index = int(tile_phase_position // cycle_length)
|
||||
phase = tile_phase_position % cycle_length
|
||||
active_count = phase + 1 if phase < led_count else (2 * led_count) - phase
|
||||
active_count = max(1, min(led_count, int(round(active_count))))
|
||||
if use_phase_bridge and previous_base_phase_position is not None:
|
||||
previous_tile_phase = previous_base_phase_position + offset
|
||||
if self._crossed_full_count_peak(previous_tile_phase, tile_phase_position, cycle_length, led_count):
|
||||
active_count = led_count
|
||||
color = self._tile_cycle_color(context, tile, cycle_index)
|
||||
|
||||
remaining = active_count
|
||||
led_pixels: dict[str, list[RGBColor]] = {}
|
||||
for segment in tile.segments:
|
||||
segment_pixels: list[RGBColor] = []
|
||||
for _index in range(segment.led_count):
|
||||
lit = remaining > 0
|
||||
segment_pixels.append(color if lit else RGBColor.black())
|
||||
if lit:
|
||||
remaining -= 1
|
||||
led_pixels[segment.name] = segment_pixels
|
||||
|
||||
activity = active_count / max(1, led_count)
|
||||
fill = color.scaled(activity)
|
||||
sample = _tile_sample(fill, color, intensity=activity, boost=0.08)
|
||||
result[tile.tile_id] = _with_led_pixels(sample, led_pixels)
|
||||
return result
|
||||
|
||||
|
||||
class SnakePattern(BasePattern):
|
||||
descriptor = PatternDescriptor(
|
||||
"snake",
|
||||
"Snake",
|
||||
"A random self-playing snake roaming across the wall.",
|
||||
("brightness", "fade", "tempo_multiplier", "color_mode", "primary_color", "secondary_color", "palette", "randomness"),
|
||||
)
|
||||
|
||||
def __init__(self) -> None:
|
||||
self._rng = random.Random(1337)
|
||||
self._shape: tuple[int, int] | None = None
|
||||
self._snake: list[tuple[int, int]] = []
|
||||
self._direction: tuple[int, int] = (0, 1)
|
||||
self._apple: tuple[int, int] | None = None
|
||||
self._blink_until_time: float | None = None
|
||||
self._target_length = 4
|
||||
self._last_time_s: float | None = None
|
||||
self._step_progress = 0.0
|
||||
|
||||
def _reset(self, rows: int, cols: int) -> None:
|
||||
start_row = rows // 2
|
||||
length = max(2, min(self._target_length, cols))
|
||||
start_col = min(cols - 1, max(length - 1, cols // 2))
|
||||
self._snake = [(start_row, start_col - index) for index in range(length)]
|
||||
self._direction = (0, 1)
|
||||
self._apple = None
|
||||
self._spawn_apple(rows, cols)
|
||||
self._blink_until_time = None
|
||||
self._shape = (rows, cols)
|
||||
self._last_time_s = None
|
||||
self._step_progress = 0.0
|
||||
|
||||
def _spawn_apple(self, rows: int, cols: int) -> None:
|
||||
occupied = set(self._snake)
|
||||
candidates = [(row, col) for row in range(rows) for col in range(cols) if (row, col) not in occupied]
|
||||
self._apple = self._rng.choice(candidates) if candidates else None
|
||||
|
||||
def _neighbors(self, head: tuple[int, int], rows: int, cols: int) -> list[tuple[int, int]]:
|
||||
row, col = head
|
||||
neighbors = []
|
||||
for d_row, d_col in ((0, 1), (1, 0), (0, -1), (-1, 0)):
|
||||
next_row = row + d_row
|
||||
next_col = col + d_col
|
||||
if 0 <= next_row < rows and 0 <= next_col < cols:
|
||||
neighbors.append((next_row, next_col))
|
||||
return neighbors
|
||||
|
||||
def _manhattan(self, cell_a: tuple[int, int], cell_b: tuple[int, int]) -> int:
|
||||
return abs(cell_a[0] - cell_b[0]) + abs(cell_a[1] - cell_b[1])
|
||||
|
||||
def _turn_left(self, direction: tuple[int, int]) -> tuple[int, int]:
|
||||
return (-direction[1], direction[0])
|
||||
|
||||
def _turn_right(self, direction: tuple[int, int]) -> tuple[int, int]:
|
||||
return (direction[1], -direction[0])
|
||||
|
||||
def _advance(self, rows: int, cols: int, randomness: float, current_time: float) -> None:
|
||||
if not self._snake:
|
||||
self._reset(rows, cols)
|
||||
head = self._snake[0]
|
||||
occupied = set(self._snake[:-1])
|
||||
candidate_directions = [
|
||||
self._direction,
|
||||
self._turn_left(self._direction),
|
||||
self._turn_right(self._direction),
|
||||
]
|
||||
candidates: list[tuple[tuple[int, int], tuple[int, int]]] = []
|
||||
for next_direction in candidate_directions:
|
||||
next_cell = (head[0] + next_direction[0], head[1] + next_direction[1])
|
||||
if not (0 <= next_cell[0] < rows and 0 <= next_cell[1] < cols):
|
||||
continue
|
||||
if next_cell in occupied:
|
||||
continue
|
||||
candidates.append((next_cell, next_direction))
|
||||
|
||||
if not candidates:
|
||||
reverse_direction = (-self._direction[0], -self._direction[1])
|
||||
reverse_cell = (head[0] + reverse_direction[0], head[1] + reverse_direction[1])
|
||||
if 0 <= reverse_cell[0] < rows and 0 <= reverse_cell[1] < cols and reverse_cell not in occupied:
|
||||
candidates.append((reverse_cell, reverse_direction))
|
||||
if not candidates:
|
||||
self._reset(rows, cols)
|
||||
return
|
||||
|
||||
def openness(cell: tuple[int, int]) -> int:
|
||||
blocked = set(self._snake[:-2]) if len(self._snake) > 2 else set()
|
||||
return sum(1 for neighbor in self._neighbors(cell, rows, cols) if neighbor not in blocked)
|
||||
|
||||
turniness = max(0.0, min(1.0, randomness / 1.5))
|
||||
best_cell, best_direction = candidates[0]
|
||||
best_score = -10_000.0
|
||||
for cell, next_direction in candidates:
|
||||
straight_bonus = 2.4 if next_direction == self._direction else 0.0
|
||||
turn_penalty = -0.55 if next_direction != self._direction else 0.0
|
||||
apple_bonus = 0.0
|
||||
if self._apple is not None:
|
||||
apple_bonus = max(0.0, (rows + cols) - self._manhattan(cell, self._apple)) * 0.7
|
||||
if cell == self._apple:
|
||||
apple_bonus += 5.0
|
||||
score = openness(cell) + straight_bonus + turn_penalty + apple_bonus + self._rng.random() * (0.08 + turniness * 0.45)
|
||||
if score > best_score:
|
||||
best_score = score
|
||||
best_cell = cell
|
||||
best_direction = next_direction
|
||||
|
||||
self._direction = best_direction
|
||||
self._snake.insert(0, best_cell)
|
||||
ate_apple = best_cell == self._apple
|
||||
if ate_apple:
|
||||
self._blink_until_time = current_time + 0.12
|
||||
self._spawn_apple(rows, cols)
|
||||
|
||||
while len(self._snake) > self._target_length:
|
||||
self._snake.pop()
|
||||
|
||||
def render(self, context: PatternContext) -> dict[str, TilePatternSample]:
|
||||
rows = max(1, context.rows)
|
||||
cols = max(1, context.cols)
|
||||
if self._shape != (rows, cols):
|
||||
self._reset(rows, cols)
|
||||
|
||||
delta_s = 0.0
|
||||
if self._last_time_s is not None:
|
||||
raw_delta = context.time_s - self._last_time_s
|
||||
if 0.0 < raw_delta <= 0.5:
|
||||
delta_s = raw_delta
|
||||
self._last_time_s = context.time_s
|
||||
|
||||
move_rate = context.pattern_tempo_hz * 2.2
|
||||
self._step_progress += delta_s * move_rate
|
||||
steps_to_run = min(3, int(self._step_progress))
|
||||
if steps_to_run:
|
||||
self._step_progress -= steps_to_run
|
||||
for _ in range(steps_to_run):
|
||||
self._advance(rows, cols, context.params.randomness, context.time_s)
|
||||
|
||||
if not self._snake:
|
||||
self._reset(rows, cols)
|
||||
self._last_time_s = context.time_s
|
||||
|
||||
body_lookup = {cell: index for index, cell in enumerate(self._snake)}
|
||||
blinking = self._blink_until_time is not None and context.time_s <= self._blink_until_time
|
||||
result: dict[str, TilePatternSample] = {}
|
||||
for tile in context.sorted_tiles():
|
||||
row_index = tile.row - 1
|
||||
col_index = tile.col - 1
|
||||
primary, secondary = _sample_for_tile(context, 0.0)
|
||||
if (row_index, col_index) in body_lookup:
|
||||
is_head = body_lookup[(row_index, col_index)] == 0
|
||||
fill = brighten(primary, 0.22) if blinking and is_head else primary
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=1.0, boost=0.06 if blinking and is_head else 0.03)
|
||||
elif self._apple == (row_index, col_index):
|
||||
fill = secondary
|
||||
accent = brighten(secondary, 0.15)
|
||||
result[tile.tile_id] = _tile_sample(fill, accent, intensity=0.92, boost=0.03)
|
||||
else:
|
||||
fill = RGBColor.black()
|
||||
result[tile.tile_id] = _tile_sample(fill, primary, intensity=0.0, boost=0.0)
|
||||
return result
|
||||
@@ -0,0 +1,115 @@
|
||||
from __future__ import annotations
|
||||
|
||||
QT_API = "unknown"
|
||||
QT_IMPORT_ERROR: Exception | None = None
|
||||
|
||||
try:
|
||||
from PySide6.QtCore import QObject, QPointF, QRectF, Qt, QTimer, Signal
|
||||
from PySide6.QtGui import (
|
||||
QAction,
|
||||
QColor,
|
||||
QFont,
|
||||
QKeySequence,
|
||||
QLinearGradient,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPalette,
|
||||
QPen,
|
||||
QRadialGradient,
|
||||
)
|
||||
from PySide6.QtWidgets import (
|
||||
QApplication,
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSlider,
|
||||
QSplitter,
|
||||
QSpinBox,
|
||||
QDoubleSpinBox,
|
||||
QStatusBar,
|
||||
QTabWidget,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QToolBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QHeaderView,
|
||||
)
|
||||
|
||||
QT_API = "PySide6"
|
||||
except ImportError as exc:
|
||||
QT_IMPORT_ERROR = exc
|
||||
|
||||
from PyQt5.QtCore import QObject, QPointF, QRectF, Qt, QTimer, pyqtSignal as Signal
|
||||
from PyQt5.QtGui import (
|
||||
QColor,
|
||||
QFont,
|
||||
QKeySequence,
|
||||
QLinearGradient,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPalette,
|
||||
QPen,
|
||||
QRadialGradient,
|
||||
)
|
||||
from PyQt5.QtWidgets import (
|
||||
QApplication,
|
||||
QAction,
|
||||
QCheckBox,
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QDialog,
|
||||
QDialogButtonBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QInputDialog,
|
||||
QLabel,
|
||||
QLineEdit,
|
||||
QListWidget,
|
||||
QListWidgetItem,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPlainTextEdit,
|
||||
QPushButton,
|
||||
QScrollArea,
|
||||
QSlider,
|
||||
QSplitter,
|
||||
QSpinBox,
|
||||
QDoubleSpinBox,
|
||||
QStatusBar,
|
||||
QTabWidget,
|
||||
QTableWidget,
|
||||
QTableWidgetItem,
|
||||
QToolBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
QHeaderView,
|
||||
)
|
||||
|
||||
QT_API = "PyQt5"
|
||||
|
||||
|
||||
def event_posf(event) -> QPointF:
|
||||
if hasattr(event, "position"):
|
||||
return event.position()
|
||||
if hasattr(event, "localPos"):
|
||||
return event.localPos()
|
||||
return QPointF()
|
||||
@@ -0,0 +1,2 @@
|
||||
"""Qt UI modules for the Infinity Mirror control app."""
|
||||
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,558 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.qt_compat import (
|
||||
QAction,
|
||||
QComboBox,
|
||||
QDoubleSpinBox,
|
||||
QFileDialog,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QKeySequence,
|
||||
QMainWindow,
|
||||
QMessageBox,
|
||||
QPushButton,
|
||||
QSplitter,
|
||||
QStatusBar,
|
||||
Qt,
|
||||
QTimer,
|
||||
QToolBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from app.config.xml_mapping import MappingValidationError
|
||||
from app.ui.pattern_panel import PatternPanel
|
||||
from app.ui.preset_browser import PresetBrowser
|
||||
from app.ui.preview_widget import (
|
||||
PREVIEW_MODE_LEDS,
|
||||
PREVIEW_MODE_TECHNICAL,
|
||||
PREVIEW_MODE_TILE,
|
||||
normalize_preview_mode,
|
||||
)
|
||||
from app.ui.scene_preview_area import ScenePreviewArea
|
||||
from app.ui.section_panel import SectionPanel
|
||||
from app.ui.settings_dialog import SettingsDialog
|
||||
|
||||
ACTIVE_UTILITY_STYLE = "QPushButton { background: #094771; color: #FFFFFF; border: 1px solid #007ACC; font-weight: 600; }"
|
||||
ALERT_UTILITY_STYLE = "QPushButton { background: #C63B1E; color: #FFFFFF; border: 1px solid #F48771; font-weight: 600; }"
|
||||
PATTERN_PANEL_MIN_WIDTH = 340
|
||||
RIGHT_PANEL_MIN_WIDTH = 380
|
||||
CENTER_PREVIEW_MIN_WIDTH = 720
|
||||
|
||||
|
||||
class MainWindow(QMainWindow):
|
||||
def __init__(self, controller, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
self.preview_mode = PREVIEW_MODE_TILE
|
||||
self._startup_splitter_sized = False
|
||||
|
||||
self.setWindowTitle("Infinity Mirror Control")
|
||||
self.resize(1840, 1040)
|
||||
self.setMinimumSize(1480, 860)
|
||||
self._blackout_blink_on = False
|
||||
self._blackout_blink_timer = QTimer(self)
|
||||
self._blackout_blink_timer.setInterval(420)
|
||||
self._blackout_blink_timer.timeout.connect(self._toggle_blackout_blink)
|
||||
self._diagnostics_timer = QTimer(self)
|
||||
self._diagnostics_timer.setInterval(500)
|
||||
self._diagnostics_timer.timeout.connect(self._refresh_diagnostics)
|
||||
|
||||
self._build_toolbar()
|
||||
self._build_central_layout()
|
||||
self._build_status_bar()
|
||||
|
||||
self.controller.status_message.connect(self.statusBar().showMessage)
|
||||
self.controller.state_changed.connect(self._refresh_state)
|
||||
self.controller.config_changed.connect(self._refresh_state)
|
||||
self._diagnostics_timer.start()
|
||||
self._refresh_state()
|
||||
|
||||
def _build_toolbar(self) -> None:
|
||||
toolbar = QToolBar("Main")
|
||||
toolbar.setMovable(False)
|
||||
self.addToolBar(toolbar)
|
||||
|
||||
open_action = QAction("Open", self)
|
||||
open_action.setShortcut(QKeySequence.Open)
|
||||
open_action.triggered.connect(self.open_mapping)
|
||||
toolbar.addAction(open_action)
|
||||
|
||||
save_action = QAction("Save", self)
|
||||
save_action.setShortcut(QKeySequence.Save)
|
||||
save_action.triggered.connect(self.save_mapping)
|
||||
toolbar.addAction(save_action)
|
||||
|
||||
save_as_action = QAction("Save As", self)
|
||||
save_as_action.setShortcut(QKeySequence("Ctrl+Shift+S"))
|
||||
save_as_action.triggered.connect(self.save_mapping_as)
|
||||
toolbar.addAction(save_as_action)
|
||||
|
||||
settings_action = QAction("Mapping Settings", self)
|
||||
settings_action.setShortcut(QKeySequence("Ctrl+,"))
|
||||
settings_action.triggered.connect(self.open_settings)
|
||||
toolbar.addAction(settings_action)
|
||||
|
||||
toolbar.addWidget(QLabel("Tempo"))
|
||||
self.tempo_spin = QDoubleSpinBox()
|
||||
self.tempo_spin.setRange(10.0, 300.0)
|
||||
self.tempo_spin.setDecimals(0)
|
||||
self.tempo_spin.setSingleStep(1.0)
|
||||
self.tempo_spin.setSuffix(" BPM")
|
||||
self.tempo_spin.setFixedWidth(96)
|
||||
self.tempo_spin.valueChanged.connect(self._change_tempo_bpm)
|
||||
toolbar.addWidget(self.tempo_spin)
|
||||
|
||||
self.foh_toggle = QPushButton("FOH Mode")
|
||||
self.foh_toggle.setCheckable(True)
|
||||
self.foh_toggle.toggled.connect(self._toggle_foh_mode)
|
||||
toolbar.addWidget(self.foh_toggle)
|
||||
|
||||
self.go_button = QPushButton("Go")
|
||||
self.go_button.clicked.connect(lambda _checked=False: self._go_scene())
|
||||
toolbar.addWidget(self.go_button)
|
||||
|
||||
self.fade_go_button = QPushButton("Fade Go")
|
||||
self.fade_go_button.clicked.connect(lambda _checked=False: self._fade_go_scene())
|
||||
toolbar.addWidget(self.fade_go_button)
|
||||
|
||||
toolbar.addWidget(QLabel("Fade"))
|
||||
self.fade_time_spin = QDoubleSpinBox()
|
||||
self.fade_time_spin.setRange(0.1, 30.0)
|
||||
self.fade_time_spin.setDecimals(1)
|
||||
self.fade_time_spin.setSingleStep(0.1)
|
||||
self.fade_time_spin.setSuffix(" s")
|
||||
self.fade_time_spin.setFixedWidth(84)
|
||||
self.fade_time_spin.valueChanged.connect(self._change_transition_duration)
|
||||
toolbar.addWidget(self.fade_time_spin)
|
||||
|
||||
self.foh_target_label = QLabel("Edit: Live")
|
||||
self.foh_target_label.setStyleSheet("color: #CCCCCC; padding-left: 8px;")
|
||||
toolbar.addWidget(self.foh_target_label)
|
||||
|
||||
self.fullscreen_action = QAction("Fullscreen Preview", self)
|
||||
self.fullscreen_action.setShortcut(QKeySequence("F11"))
|
||||
self.fullscreen_action.triggered.connect(self.toggle_fullscreen_preview)
|
||||
self.addAction(self.fullscreen_action)
|
||||
|
||||
toolbar.addSeparator()
|
||||
|
||||
self.blackout_action = QAction("Blackout", self)
|
||||
self.blackout_action.setShortcut(QKeySequence("Ctrl+B"))
|
||||
self.blackout_action.triggered.connect(lambda: self._set_utility_mode("blackout"))
|
||||
toolbar.addAction(self.blackout_action)
|
||||
|
||||
self._add_tempo_shortcuts()
|
||||
|
||||
def _add_tempo_shortcuts(self) -> None:
|
||||
for shortcut, delta in (
|
||||
("Left", -1.0),
|
||||
("Right", 1.0),
|
||||
("Shift+Left", -5.0),
|
||||
("Shift+Right", 5.0),
|
||||
):
|
||||
action = QAction(self)
|
||||
action.setShortcut(QKeySequence(shortcut))
|
||||
action.setShortcutContext(Qt.ApplicationShortcut)
|
||||
action.triggered.connect(lambda _checked=False, amount=delta: self._nudge_tempo_bpm(amount))
|
||||
self.addAction(action)
|
||||
|
||||
def _build_central_layout(self) -> None:
|
||||
splitter = QSplitter(Qt.Horizontal, self)
|
||||
splitter.setChildrenCollapsible(False)
|
||||
self.main_splitter = splitter
|
||||
|
||||
self.pattern_panel = PatternPanel(self.controller)
|
||||
self.pattern_panel.setMinimumWidth(PATTERN_PANEL_MIN_WIDTH)
|
||||
splitter.addWidget(self.pattern_panel)
|
||||
|
||||
self.preview_area = ScenePreviewArea(self.controller, preview_mode=self.preview_mode)
|
||||
splitter.addWidget(self.preview_area)
|
||||
|
||||
side_panel = QWidget()
|
||||
side_panel.setMinimumWidth(RIGHT_PANEL_MIN_WIDTH)
|
||||
self.side_panel = side_panel
|
||||
side_layout = QVBoxLayout(side_panel)
|
||||
side_layout.setContentsMargins(0, 12, 0, 0)
|
||||
side_layout.setSpacing(14)
|
||||
|
||||
self.preset_browser = PresetBrowser(self.controller)
|
||||
side_layout.addWidget(self.preset_browser)
|
||||
side_layout.addWidget(self._build_selected_tile_panel())
|
||||
side_layout.addWidget(self._build_utility_panel())
|
||||
side_layout.addStretch(1)
|
||||
side_layout.addWidget(self._build_system_panel())
|
||||
splitter.addWidget(side_panel)
|
||||
|
||||
splitter.setStretchFactor(0, 0)
|
||||
splitter.setStretchFactor(1, 1)
|
||||
splitter.setStretchFactor(2, 0)
|
||||
splitter.setSizes([PATTERN_PANEL_MIN_WIDTH, 1120, RIGHT_PANEL_MIN_WIDTH])
|
||||
self.setCentralWidget(splitter)
|
||||
|
||||
def showEvent(self, event) -> None: # type: ignore[override]
|
||||
super().showEvent(event)
|
||||
if not self._startup_splitter_sized:
|
||||
self._startup_splitter_sized = True
|
||||
QTimer.singleShot(0, self._apply_startup_splitter_sizes)
|
||||
|
||||
def _apply_startup_splitter_sizes(self) -> None:
|
||||
splitter_width = self.main_splitter.size().width()
|
||||
if splitter_width <= 0:
|
||||
return
|
||||
|
||||
left_width = max(self.pattern_panel.minimumWidth(), min(420, int(splitter_width * 0.22)))
|
||||
right_width = max(self.side_panel.minimumWidth(), min(440, int(splitter_width * 0.24)))
|
||||
center_width = max(CENTER_PREVIEW_MIN_WIDTH, splitter_width - left_width - right_width)
|
||||
|
||||
overshoot = (left_width + center_width + right_width) - splitter_width
|
||||
if overshoot > 0:
|
||||
reducible_left = max(0, left_width - self.pattern_panel.minimumWidth())
|
||||
reduce_left = min(reducible_left, overshoot // 2)
|
||||
left_width -= reduce_left
|
||||
overshoot -= reduce_left
|
||||
|
||||
reducible_right = max(0, right_width - self.side_panel.minimumWidth())
|
||||
reduce_right = min(reducible_right, overshoot)
|
||||
right_width -= reduce_right
|
||||
overshoot -= reduce_right
|
||||
|
||||
center_width = max(1, splitter_width - left_width - right_width)
|
||||
|
||||
self.main_splitter.setSizes([left_width, center_width, right_width])
|
||||
|
||||
def _build_selected_tile_panel(self) -> QWidget:
|
||||
group = SectionPanel("Selected Tile")
|
||||
layout = QVBoxLayout(group.body)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.selected_tile_label = QLabel("Click a tile in the preview.")
|
||||
self.selected_tile_label.setWordWrap(True)
|
||||
self.selected_tile_label.setStyleSheet("font-size: 14px;")
|
||||
layout.addWidget(self.selected_tile_label)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self.single_tile_button = QPushButton("White Test")
|
||||
self.single_tile_button.clicked.connect(lambda: self._set_utility_mode("single_tile"))
|
||||
self.clear_test_button = QPushButton("Live Pattern")
|
||||
self.clear_test_button.clicked.connect(lambda: self._set_utility_mode("none"))
|
||||
button_row.addWidget(self.single_tile_button)
|
||||
button_row.addWidget(self.clear_test_button)
|
||||
layout.addLayout(button_row)
|
||||
return group
|
||||
|
||||
def _build_utility_panel(self) -> QWidget:
|
||||
group = SectionPanel("Utilities")
|
||||
layout = QVBoxLayout(group.body)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.utility_buttons: dict[str, QPushButton] = {}
|
||||
for label, mode in [
|
||||
("Blackout", "blackout"),
|
||||
("Live Pattern", "none"),
|
||||
]:
|
||||
button = QPushButton(label)
|
||||
button.setCheckable(True)
|
||||
button.clicked.connect(lambda _checked=False, utility=mode: self._set_utility_mode(utility))
|
||||
layout.addWidget(button)
|
||||
self.utility_buttons[mode] = button
|
||||
return group
|
||||
|
||||
def _build_system_panel(self) -> QWidget:
|
||||
group = SectionPanel("View & Output")
|
||||
layout = QFormLayout(group.body)
|
||||
layout.setContentsMargins(12, 12, 12, 12)
|
||||
layout.setSpacing(8)
|
||||
|
||||
self.preview_mode_combo = QComboBox()
|
||||
self.preview_mode_combo.addItem("Tile Colors", PREVIEW_MODE_TILE)
|
||||
self.preview_mode_combo.addItem("Technical", PREVIEW_MODE_TECHNICAL)
|
||||
self.preview_mode_combo.addItem("LEDs Only", PREVIEW_MODE_LEDS)
|
||||
self.preview_mode_combo.currentIndexChanged.connect(self._change_preview_mode)
|
||||
layout.addRow("Preview", self.preview_mode_combo)
|
||||
|
||||
self.backend_combo = QComboBox()
|
||||
for backend_id, name in self.controller.output_manager.backend_names():
|
||||
self.backend_combo.addItem(name, backend_id)
|
||||
self.backend_combo.currentIndexChanged.connect(self._change_backend)
|
||||
layout.addRow("Backend", self.backend_combo)
|
||||
|
||||
self.output_toggle = QPushButton("Enable Output")
|
||||
self.output_toggle.setCheckable(True)
|
||||
self.output_toggle.clicked.connect(self._toggle_output)
|
||||
layout.addRow("Output", self.output_toggle)
|
||||
|
||||
self.output_fps_spin = QDoubleSpinBox()
|
||||
self.output_fps_spin.setRange(1.0, 60.0)
|
||||
self.output_fps_spin.setDecimals(0)
|
||||
self.output_fps_spin.setSingleStep(1.0)
|
||||
self.output_fps_spin.setSuffix(" fps")
|
||||
self.output_fps_spin.setFixedWidth(84)
|
||||
self.output_fps_spin.valueChanged.connect(self._change_output_target_fps)
|
||||
layout.addRow("Output FPS", self.output_fps_spin)
|
||||
|
||||
self.render_fps_value = QLabel("--")
|
||||
self.render_fps_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
layout.addRow("Render FPS", self.render_fps_value)
|
||||
|
||||
self.send_fps_value = QLabel("--")
|
||||
self.send_fps_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
layout.addRow("Send FPS", self.send_fps_value)
|
||||
|
||||
self.output_health_value = QLabel("--")
|
||||
self.output_health_value.setWordWrap(True)
|
||||
self.output_health_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
layout.addRow("Output Health", self.output_health_value)
|
||||
|
||||
self.controller_fps_value = QLabel("n/a")
|
||||
self.controller_fps_value.setWordWrap(True)
|
||||
self.controller_fps_value.setTextInteractionFlags(Qt.TextSelectableByMouse)
|
||||
layout.addRow("Controller FPS", self.controller_fps_value)
|
||||
|
||||
self.fullscreen_button = QPushButton("Fullscreen Preview")
|
||||
self.fullscreen_button.clicked.connect(self.toggle_fullscreen_preview)
|
||||
layout.addRow("Window", self.fullscreen_button)
|
||||
return group
|
||||
|
||||
def _build_status_bar(self) -> None:
|
||||
self.setStatusBar(QStatusBar(self))
|
||||
self.statusBar().showMessage("Ready")
|
||||
self.mapping_status_label = QLabel("")
|
||||
self.mapping_status_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
self.statusBar().addPermanentWidget(self.mapping_status_label, 1)
|
||||
|
||||
def _toggle_blackout_blink(self) -> None:
|
||||
self._blackout_blink_on = not self._blackout_blink_on
|
||||
self._apply_live_pattern_blink()
|
||||
|
||||
def _apply_live_pattern_blink(self) -> None:
|
||||
override_mode = self.controller.utility_mode
|
||||
override_active = override_mode in {"blackout", "single_tile"}
|
||||
style = ALERT_UTILITY_STYLE if override_active and self._blackout_blink_on else ""
|
||||
|
||||
self.clear_test_button.setStyleSheet(style)
|
||||
live_pattern_button = self.utility_buttons.get("none")
|
||||
if live_pattern_button is not None:
|
||||
live_pattern_button.setStyleSheet(style)
|
||||
|
||||
self.single_tile_button.setStyleSheet(ACTIVE_UTILITY_STYLE if override_mode == "single_tile" else "")
|
||||
blackout_button = self.utility_buttons.get("blackout")
|
||||
if blackout_button is not None:
|
||||
blackout_button.setStyleSheet(ACTIVE_UTILITY_STYLE if override_mode == "blackout" else "")
|
||||
|
||||
def open_mapping(self) -> None:
|
||||
path, _ = QFileDialog.getOpenFileName(
|
||||
self,
|
||||
"Open Mapping",
|
||||
str(self.controller.mapping_path.parent if self.controller.mapping_path else Path.home()),
|
||||
"XML Files (*.xml)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
self.controller.load_mapping(path)
|
||||
except MappingValidationError as exc:
|
||||
QMessageBox.warning(self, "Mapping Error", "\n".join(exc.errors))
|
||||
|
||||
def save_mapping(self) -> None:
|
||||
if self.controller.mapping_path is None:
|
||||
self.save_mapping_as()
|
||||
return
|
||||
try:
|
||||
self.controller.save_mapping()
|
||||
except MappingValidationError as exc:
|
||||
QMessageBox.warning(self, "Save Error", "\n".join(exc.errors))
|
||||
|
||||
def save_mapping_as(self) -> None:
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
self,
|
||||
"Save Mapping As",
|
||||
str(self.controller.mapping_path or (Path.home() / "infinity_mirror_mapping.xml")),
|
||||
"XML Files (*.xml)",
|
||||
)
|
||||
if not path:
|
||||
return
|
||||
try:
|
||||
self.controller.save_mapping(path)
|
||||
except MappingValidationError as exc:
|
||||
QMessageBox.warning(self, "Save Error", "\n".join(exc.errors))
|
||||
|
||||
def open_settings(self) -> None:
|
||||
dialog = SettingsDialog(self.controller.config, controller=self.controller, parent=self)
|
||||
if dialog.exec() == SettingsDialog.Accepted and dialog.result_config is not None:
|
||||
self.controller.replace_config(dialog.result_config)
|
||||
self.statusBar().showMessage("Mapping updated in memory. Save to write XML.", 4000)
|
||||
|
||||
def _change_preview_mode(self) -> None:
|
||||
self.preview_mode = normalize_preview_mode(self.preview_mode_combo.currentData())
|
||||
self.preview_area.set_preview_mode(self.preview_mode)
|
||||
|
||||
def _toggle_foh_mode(self, enabled: bool) -> None:
|
||||
self.controller.set_foh_mode(enabled)
|
||||
|
||||
def _go_scene(self) -> None:
|
||||
self.controller.go_scene()
|
||||
|
||||
def _fade_go_scene(self) -> None:
|
||||
self.controller.fade_go(self.fade_time_spin.value())
|
||||
|
||||
def _change_transition_duration(self, value: float) -> None:
|
||||
self.controller.set_transition_duration(value)
|
||||
|
||||
def _change_tempo_bpm(self, value: float) -> None:
|
||||
self.controller.set_tempo_bpm(value)
|
||||
|
||||
def _nudge_tempo_bpm(self, delta: float) -> None:
|
||||
self.controller.set_tempo_bpm(self.controller.tempo_bpm + delta)
|
||||
|
||||
def toggle_technical_preview(self, enabled: bool) -> None:
|
||||
self.preview_mode = PREVIEW_MODE_TECHNICAL if enabled else PREVIEW_MODE_TILE
|
||||
self.preview_area.set_preview_mode(self.preview_mode)
|
||||
|
||||
def toggle_fullscreen_preview(self) -> None:
|
||||
self.preview_area.toggle_fullscreen()
|
||||
|
||||
def _change_backend(self) -> None:
|
||||
self.controller.set_backend(self.backend_combo.currentData())
|
||||
|
||||
def _toggle_output(self) -> None:
|
||||
self.controller.set_output_enabled(self.output_toggle.isChecked())
|
||||
|
||||
def _change_output_target_fps(self, value: float) -> None:
|
||||
self.controller.set_output_target_fps(value)
|
||||
|
||||
def _set_utility_mode(self, mode: str) -> None:
|
||||
if mode == "none":
|
||||
self.controller.clear_utility_mode()
|
||||
else:
|
||||
self.controller.set_utility_mode(mode)
|
||||
|
||||
def _pattern_display_name(self, pattern_id: str) -> str:
|
||||
for descriptor in self.controller.available_patterns():
|
||||
if descriptor.pattern_id == pattern_id:
|
||||
return descriptor.display_name
|
||||
return pattern_id.replace("_", " ").title()
|
||||
|
||||
def _refresh_scene_labels(self) -> None:
|
||||
live_name = self._pattern_display_name(self.controller.scene_state("live").pattern_id)
|
||||
next_name = self._pattern_display_name(self.controller.scene_state("next").pattern_id)
|
||||
self.preview_area.set_scene_labels(
|
||||
"Live | Fading" if self.controller.transition_active else f"Live | {live_name}",
|
||||
f"Next | {next_name}",
|
||||
)
|
||||
|
||||
def _refresh_state(self) -> None:
|
||||
mapping_name = self.controller.mapping_path.name if self.controller.mapping_path else "Unsaved Mapping"
|
||||
self.mapping_status_label.setText(mapping_name)
|
||||
self._refresh_scene_labels()
|
||||
self.preview_area.set_foh_mode(self.controller.foh_mode_enabled)
|
||||
|
||||
preview_index = self.preview_mode_combo.findData(self.preview_mode)
|
||||
self.preview_mode_combo.blockSignals(True)
|
||||
self.preview_mode_combo.setCurrentIndex(max(0, preview_index))
|
||||
self.preview_mode_combo.blockSignals(False)
|
||||
|
||||
self.tempo_spin.blockSignals(True)
|
||||
self.tempo_spin.setValue(self.controller.tempo_bpm)
|
||||
self.tempo_spin.blockSignals(False)
|
||||
|
||||
self.foh_toggle.blockSignals(True)
|
||||
self.foh_toggle.setChecked(self.controller.foh_mode_enabled)
|
||||
self.foh_toggle.blockSignals(False)
|
||||
self.go_button.setEnabled(self.controller.foh_mode_enabled)
|
||||
self.fade_go_button.setEnabled(self.controller.foh_mode_enabled)
|
||||
self.fade_time_spin.blockSignals(True)
|
||||
self.fade_time_spin.setValue(self.controller.transition_duration_s)
|
||||
self.fade_time_spin.blockSignals(False)
|
||||
self.fade_time_spin.setEnabled(self.controller.foh_mode_enabled)
|
||||
self.foh_target_label.setText("Edit: Next" if self.controller.foh_mode_enabled else "Edit: Live")
|
||||
|
||||
backend_index = self.backend_combo.findData(self.controller.output_manager.active_backend_id)
|
||||
self.backend_combo.blockSignals(True)
|
||||
self.backend_combo.setCurrentIndex(max(0, backend_index))
|
||||
self.backend_combo.blockSignals(False)
|
||||
|
||||
self.output_toggle.blockSignals(True)
|
||||
self.output_toggle.setChecked(self.controller.output_manager.output_enabled)
|
||||
self.output_toggle.setText("Output Enabled" if self.controller.output_manager.output_enabled else "Enable Output")
|
||||
self.output_toggle.blockSignals(False)
|
||||
|
||||
self.output_fps_spin.blockSignals(True)
|
||||
self.output_fps_spin.setValue(self.controller.output_manager.target_fps())
|
||||
self.output_fps_spin.blockSignals(False)
|
||||
|
||||
if self.controller.utility_mode in {"blackout", "single_tile"}:
|
||||
self._blackout_blink_on = True
|
||||
if not self._blackout_blink_timer.isActive():
|
||||
self._blackout_blink_timer.start()
|
||||
self._apply_live_pattern_blink()
|
||||
else:
|
||||
if self._blackout_blink_timer.isActive():
|
||||
self._blackout_blink_timer.stop()
|
||||
self._blackout_blink_on = False
|
||||
self._apply_live_pattern_blink()
|
||||
|
||||
tile = self.controller.config.tile_lookup().get(self.controller.selected_tile_id) if self.controller.selected_tile_id else None
|
||||
if tile is None:
|
||||
self.selected_tile_label.setText("Click a tile in the preview to inspect or run a single-tile white test.")
|
||||
self.single_tile_button.setEnabled(False)
|
||||
else:
|
||||
self.selected_tile_label.setText(
|
||||
f"{tile.tile_id}\n{tile.screen_name or tile.controller_ip}\nRow {tile.row}, Col {tile.col} | Universe {tile.universe} | {tile.led_total} LEDs"
|
||||
)
|
||||
self.single_tile_button.setEnabled(True)
|
||||
|
||||
for mode, button in self.utility_buttons.items():
|
||||
active = self.controller.utility_mode == mode or (mode == "none" and self.controller.utility_mode == "none")
|
||||
button.setChecked(active)
|
||||
|
||||
self._refresh_diagnostics()
|
||||
|
||||
def _refresh_diagnostics(self) -> None:
|
||||
diagnostics = self.controller.realtime_diagnostics()
|
||||
|
||||
render_text = "--" if diagnostics.render_fps <= 0.0 else f"{diagnostics.render_fps:.1f} fps"
|
||||
self.render_fps_value.setText(render_text)
|
||||
|
||||
if diagnostics.output_enabled:
|
||||
send_text = f"{diagnostics.send_fps:.1f} fps via {diagnostics.backend_name}"
|
||||
else:
|
||||
send_text = f"0.0 fps via {diagnostics.backend_name}"
|
||||
self.send_fps_value.setText(send_text)
|
||||
self.send_fps_value.setToolTip(
|
||||
f"Target {diagnostics.target_output_fps:.0f} fps\n"
|
||||
f"Last send {diagnostics.last_send_time_ms:.1f} ms\n"
|
||||
f"Last schedule slip {diagnostics.last_schedule_slip_ms:.1f} ms"
|
||||
)
|
||||
|
||||
health_parts = [
|
||||
f"target {diagnostics.target_output_fps:.0f} fps",
|
||||
f"stale drops {diagnostics.stale_frame_drops}",
|
||||
f"budget misses {diagnostics.send_budget_misses}",
|
||||
f"last send {diagnostics.last_send_time_ms:.1f} ms",
|
||||
]
|
||||
if diagnostics.send_failures:
|
||||
health_parts.append(f"send failures {diagnostics.send_failures}")
|
||||
self.output_health_value.setText(" | ".join(health_parts))
|
||||
|
||||
if diagnostics.controller_fps is None:
|
||||
controller_text = "n/a"
|
||||
if diagnostics.controller_source:
|
||||
if "disabled during live output" in diagnostics.controller_source.lower():
|
||||
controller_text = "n/a (disabled)"
|
||||
elif diagnostics.controller_total_devices > 0:
|
||||
controller_text = f"n/a ({diagnostics.controller_live_devices}/{diagnostics.controller_total_devices} live)"
|
||||
else:
|
||||
controller_text = "n/a"
|
||||
else:
|
||||
controller_text = (
|
||||
f"{diagnostics.controller_fps:.1f} fps avg "
|
||||
f"({diagnostics.controller_live_devices}/{diagnostics.controller_total_devices} live)"
|
||||
)
|
||||
self.controller_fps_value.setText(controller_text)
|
||||
self.controller_fps_value.setToolTip(diagnostics.controller_source or "No verified controller-side FPS source for this backend.")
|
||||
@@ -0,0 +1,193 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import (
|
||||
QColor,
|
||||
QFont,
|
||||
QLinearGradient,
|
||||
QPainter,
|
||||
QPainterPath,
|
||||
QPen,
|
||||
QRectF,
|
||||
Qt,
|
||||
Signal,
|
||||
QWidget,
|
||||
event_posf,
|
||||
)
|
||||
|
||||
from app.config.device_assignment import tile_matches_device
|
||||
from app.config.models import InfinityMirrorConfig
|
||||
from app.network.wled import DiscoveredWledDevice, normalize_mac_address
|
||||
from app.ui.preview_layout import compute_preview_layout
|
||||
|
||||
|
||||
class MappingAssignmentPreview(QWidget):
|
||||
tileClicked = Signal(str)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._config = InfinityMirrorConfig()
|
||||
self._discovered_devices: list[DiscoveredWledDevice] = []
|
||||
self._active_device: DiscoveredWledDevice | None = None
|
||||
self._selected_tile_id: str | None = None
|
||||
self._tile_rects: dict[str, QRectF] = {}
|
||||
|
||||
self.setMinimumSize(520, 360)
|
||||
self.setMouseTracking(True)
|
||||
|
||||
def set_assignment_state(
|
||||
self,
|
||||
config: InfinityMirrorConfig,
|
||||
discovered_devices: list[DiscoveredWledDevice],
|
||||
*,
|
||||
active_device: DiscoveredWledDevice | None = None,
|
||||
selected_tile_id: str | None = None,
|
||||
) -> None:
|
||||
self._config = config
|
||||
self._discovered_devices = list(discovered_devices)
|
||||
self._active_device = active_device
|
||||
self._selected_tile_id = selected_tile_id
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
point = event_posf(event)
|
||||
for tile_id, rect in self._tile_rects.items():
|
||||
if rect.contains(point):
|
||||
self.tileClicked.emit(tile_id)
|
||||
break
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def paintEvent(self, event) -> None: # type: ignore[override]
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
painter.setRenderHint(QPainter.TextAntialiasing, True)
|
||||
|
||||
rect = QRectF(self.rect())
|
||||
background = QLinearGradient(rect.topLeft(), rect.bottomRight())
|
||||
background.setColorAt(0.0, QColor("#12161D"))
|
||||
background.setColorAt(1.0, QColor("#1B2230"))
|
||||
painter.fillRect(rect, background)
|
||||
|
||||
if not self._config.tiles:
|
||||
painter.setPen(QColor("#A8B3C7"))
|
||||
painter.drawText(rect, Qt.AlignCenter, "Open a mapping to assign WLED devices.")
|
||||
return
|
||||
|
||||
layout = compute_preview_layout(rect, self._config)
|
||||
self._tile_rects = layout.tile_rects
|
||||
self._draw_canvas_shell(painter, layout.canvas_rect)
|
||||
|
||||
discovered_ips = {device.ip_address for device in self._discovered_devices}
|
||||
discovered_macs = {normalize_mac_address(device.mac_address) for device in self._discovered_devices if device.mac_address}
|
||||
active_tile_id = None
|
||||
if self._active_device is not None:
|
||||
for tile in self._config.sorted_tiles():
|
||||
if tile_matches_device(tile, self._active_device):
|
||||
active_tile_id = tile.tile_id
|
||||
break
|
||||
|
||||
for tile in self._config.sorted_tiles():
|
||||
tile_rect = layout.tile_rects[tile.tile_id]
|
||||
tile_assigned = bool(tile.controller_ip.strip() or tile.controller_mac.strip())
|
||||
tile_is_active = active_tile_id == tile.tile_id
|
||||
tile_is_selected = self._selected_tile_id == tile.tile_id
|
||||
|
||||
if tile_is_active:
|
||||
fill_color = QColor("#1D4E89")
|
||||
outline_color = QColor("#90CAF9")
|
||||
subtitle = self._active_device.instance_name or self._active_device.ip_address
|
||||
status = "Active Device"
|
||||
elif not tile_assigned:
|
||||
fill_color = QColor("#2A2F3A")
|
||||
outline_color = QColor("#4A5568")
|
||||
subtitle = "Unmapped"
|
||||
status = "Click to assign"
|
||||
elif (tile.controller_mac and normalize_mac_address(tile.controller_mac) in discovered_macs) or tile.controller_ip.strip() in discovered_ips:
|
||||
fill_color = QColor("#1D5A45")
|
||||
outline_color = QColor("#81E6D9")
|
||||
subtitle = tile.controller_name or tile.controller_host or tile.controller_ip
|
||||
status = "Mapped"
|
||||
else:
|
||||
fill_color = QColor("#6B4F1D")
|
||||
outline_color = QColor("#F6AD55")
|
||||
subtitle = tile.controller_name or tile.controller_host or tile.controller_ip
|
||||
status = "Assigned, not seen"
|
||||
|
||||
self._draw_tile(
|
||||
painter,
|
||||
rect=tile_rect,
|
||||
tile_id=tile.tile_id,
|
||||
row=tile.row,
|
||||
col=tile.col,
|
||||
subtitle=subtitle,
|
||||
status=status,
|
||||
fill_color=fill_color,
|
||||
outline_color=outline_color,
|
||||
selected=tile_is_selected,
|
||||
)
|
||||
|
||||
def _draw_canvas_shell(self, painter: QPainter, rect: QRectF) -> None:
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(rect, 10.0, 10.0)
|
||||
painter.fillPath(path, QColor("#202632"))
|
||||
painter.setPen(QPen(QColor("#334155"), 1.0))
|
||||
painter.drawPath(path)
|
||||
|
||||
def _draw_tile(
|
||||
self,
|
||||
painter: QPainter,
|
||||
*,
|
||||
rect: QRectF,
|
||||
tile_id: str,
|
||||
row: int,
|
||||
col: int,
|
||||
subtitle: str,
|
||||
status: str,
|
||||
fill_color: QColor,
|
||||
outline_color: QColor,
|
||||
selected: bool,
|
||||
) -> None:
|
||||
base = min(rect.width(), rect.height())
|
||||
rounding = max(4.0, base * 0.045)
|
||||
tile_path = QPainterPath()
|
||||
tile_path.addRoundedRect(rect, rounding, rounding)
|
||||
|
||||
painter.fillPath(tile_path, fill_color)
|
||||
|
||||
highlight = QLinearGradient(rect.topLeft(), rect.bottomLeft())
|
||||
highlight.setColorAt(0.0, QColor(255, 255, 255, 24))
|
||||
highlight.setColorAt(0.18, QColor(255, 255, 255, 8))
|
||||
highlight.setColorAt(1.0, QColor(0, 0, 0, 0))
|
||||
painter.fillPath(tile_path, highlight)
|
||||
|
||||
painter.setPen(QPen(outline_color, 1.3))
|
||||
painter.drawPath(tile_path)
|
||||
|
||||
if selected:
|
||||
painter.setPen(QPen(QColor("#E2E8F0"), 2.1))
|
||||
painter.drawRoundedRect(rect.adjusted(-3, -3, 3, 3), rounding + 2.0, rounding + 2.0)
|
||||
|
||||
padding_x = max(12.0, rect.width() * 0.08)
|
||||
padding_top = max(10.0, rect.height() * 0.08)
|
||||
padding_bottom = max(12.0, rect.height() * 0.08)
|
||||
|
||||
title_font = QFont()
|
||||
title_font.setPointSizeF(max(13.0, base * 0.1))
|
||||
title_font.setWeight(QFont.DemiBold)
|
||||
painter.setFont(title_font)
|
||||
painter.setPen(QColor("#F8FAFC"))
|
||||
painter.drawText(
|
||||
rect.adjusted(padding_x, padding_top, -padding_x, -rect.height() * 0.56),
|
||||
Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap,
|
||||
tile_id,
|
||||
)
|
||||
|
||||
meta_font = QFont(title_font)
|
||||
meta_font.setPointSizeF(max(9.2, base * 0.06))
|
||||
meta_font.setWeight(QFont.Normal)
|
||||
painter.setFont(meta_font)
|
||||
painter.setPen(QColor(235, 244, 249, 175))
|
||||
painter.drawText(
|
||||
rect.adjusted(padding_x, rect.height() * 0.48, -padding_x, -padding_bottom),
|
||||
Qt.AlignLeft | Qt.AlignBottom | Qt.TextWordWrap,
|
||||
f"{subtitle}\n{status} | R{row} C{col}",
|
||||
)
|
||||
@@ -0,0 +1,382 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.qt_compat import (
|
||||
QCheckBox,
|
||||
QColor,
|
||||
QColorDialog,
|
||||
QComboBox,
|
||||
QFont,
|
||||
QFormLayout,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPainter,
|
||||
QPen,
|
||||
QPointF,
|
||||
QPushButton,
|
||||
QRectF,
|
||||
QScrollArea,
|
||||
QSlider,
|
||||
Qt,
|
||||
Signal,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
event_posf,
|
||||
)
|
||||
|
||||
from app.core.colors import PALETTES, canonical_palette_name
|
||||
from app.patterns.base import COMMON_PARAMETER_SPECS
|
||||
from app.ui.section_panel import SectionPanel
|
||||
|
||||
|
||||
class SliderField(QWidget):
|
||||
valueChanged = Signal(float)
|
||||
|
||||
def __init__(self, minimum: float, maximum: float, step: float, decimals: int = 2, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.minimum = minimum
|
||||
self.maximum = maximum
|
||||
self.step = step
|
||||
self.decimals = decimals
|
||||
|
||||
self.slider = QSlider(Qt.Horizontal, self)
|
||||
self.slider.setMinimum(0)
|
||||
self.slider.setMaximum(int(round((maximum - minimum) / step)))
|
||||
self.value_label = QLabel(self)
|
||||
self.value_label.setFixedWidth(64)
|
||||
self.value_label.setAlignment(Qt.AlignRight | Qt.AlignVCenter)
|
||||
|
||||
layout = QHBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.addWidget(self.slider, 1)
|
||||
layout.addWidget(self.value_label)
|
||||
|
||||
self.slider.valueChanged.connect(self._on_slider_changed)
|
||||
self.set_value(minimum)
|
||||
|
||||
def value(self) -> float:
|
||||
return self.minimum + self.slider.value() * self.step
|
||||
|
||||
def set_value(self, value: float) -> None:
|
||||
clamped = max(self.minimum, min(self.maximum, float(value)))
|
||||
scaled = int(round((clamped - self.minimum) / self.step))
|
||||
scaled = max(self.slider.minimum(), min(self.slider.maximum(), scaled))
|
||||
self.slider.blockSignals(True)
|
||||
self.slider.setValue(scaled)
|
||||
self.slider.blockSignals(False)
|
||||
self._update_label()
|
||||
|
||||
def _on_slider_changed(self, _: int) -> None:
|
||||
self._update_label()
|
||||
self.valueChanged.emit(self.value())
|
||||
|
||||
def _update_label(self) -> None:
|
||||
self.value_label.setText(f"{self.value():.{self.decimals}f}")
|
||||
|
||||
|
||||
class ClickableLabel(QLabel):
|
||||
clicked = Signal()
|
||||
|
||||
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
||||
if event.button() == Qt.LeftButton:
|
||||
self.clicked.emit()
|
||||
event.accept()
|
||||
return
|
||||
super().mousePressEvent(event)
|
||||
|
||||
|
||||
class ColorButton(QPushButton):
|
||||
colorChanged = Signal(str)
|
||||
|
||||
def __init__(self, color_hex: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._color_hex = color_hex
|
||||
self.clicked.connect(self.choose_color)
|
||||
self.setToolTip("Open a color picker.")
|
||||
self.set_color(color_hex)
|
||||
|
||||
def color(self) -> str:
|
||||
return self._color_hex
|
||||
|
||||
def set_color(self, color_hex: str) -> None:
|
||||
self._color_hex = color_hex
|
||||
color = QColor(color_hex)
|
||||
text_color = "#09120F" if color.lightnessF() > 0.62 else "#E8F0F4"
|
||||
self.setText(color_hex.upper())
|
||||
self.setStyleSheet(
|
||||
f"QPushButton {{ background: {color_hex}; color: {text_color}; border: 1px solid rgba(255,255,255,0.16); }}"
|
||||
)
|
||||
|
||||
def choose_color(self) -> None:
|
||||
color = QColorDialog.getColor(QColor(self._color_hex), self.window(), "Choose Color")
|
||||
if color.isValid():
|
||||
self.set_color(color.name())
|
||||
self.colorChanged.emit(color.name())
|
||||
|
||||
|
||||
class AngleSelector(QWidget):
|
||||
valueChanged = Signal(float)
|
||||
|
||||
_ANGLES = (0, 45, 90, 135, 180, 225, 270, 315)
|
||||
|
||||
def __init__(self, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self._value = 0
|
||||
self.setMinimumSize(118, 118)
|
||||
self.setMaximumHeight(132)
|
||||
|
||||
def value(self) -> float:
|
||||
return float(self._value)
|
||||
|
||||
def set_value(self, value: float) -> None:
|
||||
snapped = self._snap_angle(value)
|
||||
if snapped != self._value:
|
||||
self._value = snapped
|
||||
self.update()
|
||||
|
||||
def _snap_angle(self, value: float) -> int:
|
||||
angle = int(round(float(value))) % 360
|
||||
return min(self._ANGLES, key=lambda candidate: min((candidate - angle) % 360, (angle - candidate) % 360))
|
||||
|
||||
def _point_for_angle(self, center: QPointF, radius: float, angle: int) -> QPointF:
|
||||
radians = math.radians(angle)
|
||||
return QPointF(center.x() + math.cos(radians) * radius, center.y() + math.sin(radians) * radius)
|
||||
|
||||
def mousePressEvent(self, event) -> None: # type: ignore[override]
|
||||
pos = event_posf(event)
|
||||
center = QPointF(self.width() / 2.0, self.height() / 2.0)
|
||||
dx = pos.x() - center.x()
|
||||
dy = pos.y() - center.y()
|
||||
if dx == 0.0 and dy == 0.0:
|
||||
return
|
||||
angle = (math.degrees(math.atan2(dy, dx)) + 360.0) % 360.0
|
||||
snapped = self._snap_angle(angle)
|
||||
if snapped != self._value:
|
||||
self._value = snapped
|
||||
self.update()
|
||||
self.valueChanged.emit(float(snapped))
|
||||
|
||||
def paintEvent(self, _event) -> None: # type: ignore[override]
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
|
||||
bounds = QRectF(self.rect()).adjusted(8.0, 8.0, -8.0, -8.0)
|
||||
center = bounds.center()
|
||||
outer_radius = min(bounds.width(), bounds.height()) * 0.34
|
||||
label_radius = outer_radius + 15.0
|
||||
|
||||
painter.setPen(QPen(QColor("#3C3C3C"), 1.2))
|
||||
painter.setBrush(QColor("#252526"))
|
||||
painter.drawEllipse(center, outer_radius, outer_radius)
|
||||
|
||||
selected_point = self._point_for_angle(center, outer_radius - 10.0, self._value)
|
||||
painter.setPen(QPen(QColor("#007ACC"), 3.0))
|
||||
painter.drawLine(center, selected_point)
|
||||
painter.setBrush(QColor("#007ACC"))
|
||||
painter.drawEllipse(selected_point, 6.5, 6.5)
|
||||
|
||||
label_font = QFont(self.font())
|
||||
label_font.setPointSizeF(7.6)
|
||||
label_font.setWeight(QFont.Medium)
|
||||
painter.setFont(label_font)
|
||||
|
||||
for angle in self._ANGLES:
|
||||
node = self._point_for_angle(center, outer_radius, angle)
|
||||
active = angle == self._value
|
||||
painter.setPen(QPen(QColor("#007ACC") if active else QColor("#5A5A5A"), 1.2))
|
||||
painter.setBrush(QColor("#007ACC") if active else QColor("#2D2D30"))
|
||||
painter.drawEllipse(node, 5.5 if active else 4.5, 5.5 if active else 4.5)
|
||||
|
||||
label_point = self._point_for_angle(center, label_radius, angle)
|
||||
label_rect = QRectF(label_point.x() - 16.0, label_point.y() - 8.0, 32.0, 16.0)
|
||||
painter.setPen(QColor("#FFFFFF") if active else QColor("#A8A8A8"))
|
||||
painter.drawText(label_rect, Qt.AlignCenter, f"{angle}\N{DEGREE SIGN}")
|
||||
|
||||
painter.setPen(QColor("#A8A8A8"))
|
||||
painter.drawText(QRectF(center.x() - 26.0, center.y() - 10.0, 52.0, 20.0), Qt.AlignCenter, f"{self._value}\N{DEGREE SIGN}")
|
||||
painter.end()
|
||||
|
||||
|
||||
class PatternPanel(QWidget):
|
||||
def __init__(self, controller, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
self._updating = False
|
||||
self._rows: dict[str, tuple[QLabel, QWidget]] = {}
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
root_layout.setContentsMargins(0, 0, 0, 0)
|
||||
root_layout.setSpacing(0)
|
||||
|
||||
scroll = QScrollArea(self)
|
||||
scroll.setWidgetResizable(True)
|
||||
scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
|
||||
scroll.setStyleSheet("QScrollArea { border: 0; background: transparent; }")
|
||||
root_layout.addWidget(scroll, 1)
|
||||
|
||||
content = QWidget()
|
||||
scroll.setWidget(content)
|
||||
|
||||
content_layout = QVBoxLayout(content)
|
||||
content_layout.setContentsMargins(0, 12, 0, 0)
|
||||
content_layout.setSpacing(14)
|
||||
|
||||
pattern_group = SectionPanel("Pattern")
|
||||
pattern_form = QFormLayout(pattern_group.body)
|
||||
pattern_form.setContentsMargins(12, 12, 12, 12)
|
||||
pattern_form.setSpacing(8)
|
||||
self.pattern_combo = QComboBox()
|
||||
for descriptor in self.controller.available_patterns():
|
||||
self.pattern_combo.addItem(descriptor.display_name, descriptor.pattern_id)
|
||||
pattern_form.addRow("Pattern", self.pattern_combo)
|
||||
content_layout.addWidget(pattern_group)
|
||||
|
||||
controls_group = SectionPanel("Look & Motion")
|
||||
self.controls_form = QFormLayout(controls_group.body)
|
||||
self.controls_form.setContentsMargins(12, 12, 12, 12)
|
||||
self.controls_form.setSpacing(8)
|
||||
content_layout.addWidget(controls_group)
|
||||
content_layout.addStretch(1)
|
||||
|
||||
self.widgets: dict[str, QWidget] = {}
|
||||
self._build_controls()
|
||||
|
||||
self.pattern_combo.currentIndexChanged.connect(self._on_pattern_changed)
|
||||
self.controller.state_changed.connect(self.refresh_from_state)
|
||||
self.refresh_from_state()
|
||||
|
||||
def _build_controls(self) -> None:
|
||||
self._add_combo("color_mode", list(COMMON_PARAMETER_SPECS["color_mode"].options))
|
||||
self._add_combo("palette", [(name, name) for name in PALETTES])
|
||||
self._add_color("primary_color")
|
||||
self._add_color("secondary_color")
|
||||
self._add_combo("direction", list(COMMON_PARAMETER_SPECS["direction"].options))
|
||||
self._add_angle("angle")
|
||||
self._add_combo("scan_style", list(COMMON_PARAMETER_SPECS["scan_style"].options))
|
||||
self._add_combo("checker_mode", list(COMMON_PARAMETER_SPECS["checker_mode"].options))
|
||||
self._add_combo("strobe_mode", list(COMMON_PARAMETER_SPECS["strobe_mode"].options))
|
||||
self._add_combo("stopwatch_mode", list(COMMON_PARAMETER_SPECS["stopwatch_mode"].options))
|
||||
self._add_combo("symmetry", list(COMMON_PARAMETER_SPECS["symmetry"].options))
|
||||
self._add_combo("center_pulse_mode", list(COMMON_PARAMETER_SPECS["center_pulse_mode"].options))
|
||||
self._add_slider("brightness")
|
||||
self._add_slider("fade")
|
||||
self._add_slider("on_width")
|
||||
self._add_slider("off_width")
|
||||
self._add_slider("block_size")
|
||||
self._add_slider("pixel_group_size")
|
||||
self._add_slider("strobe_duty_cycle")
|
||||
self._add_slider("randomness")
|
||||
self._add_slider("tempo_multiplier")
|
||||
|
||||
def _add_row(self, key: str, label_text: str, widget: QWidget) -> None:
|
||||
spec = COMMON_PARAMETER_SPECS[key]
|
||||
label: QLabel = QLabel(label_text)
|
||||
if spec.kind == "slider" and spec.reset_value is not None:
|
||||
clickable_label = ClickableLabel(label_text)
|
||||
clickable_label.setCursor(Qt.PointingHandCursor)
|
||||
clickable_label.clicked.connect(lambda field=key, reset_value=spec.reset_value: self._reset_slider(field, reset_value))
|
||||
label = clickable_label
|
||||
self.controls_form.addRow(label, widget)
|
||||
self._rows[key] = (label, widget)
|
||||
self.widgets[key] = widget
|
||||
tooltip = spec.tooltip
|
||||
if spec.kind == "slider" and spec.reset_value is not None:
|
||||
tooltip = f"{tooltip} Click the label to reset." if tooltip else "Click the label to reset."
|
||||
label.setToolTip(tooltip)
|
||||
widget.setToolTip(tooltip)
|
||||
|
||||
def _add_combo(self, key: str, options: list[tuple[str, str]]) -> None:
|
||||
combo = QComboBox()
|
||||
for value, label in options:
|
||||
combo.addItem(label, value)
|
||||
combo.currentIndexChanged.connect(lambda _: self._on_combo_changed(key))
|
||||
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, combo)
|
||||
|
||||
def _add_slider(self, key: str) -> None:
|
||||
spec = COMMON_PARAMETER_SPECS[key]
|
||||
decimals = 2 if spec.step < 0.1 else 1
|
||||
slider = SliderField(spec.minimum, spec.maximum, spec.step, decimals=decimals)
|
||||
slider.valueChanged.connect(lambda value, field=key: self._on_slider_changed(field, value))
|
||||
self._add_row(key, spec.label, slider)
|
||||
|
||||
def _add_angle(self, key: str) -> None:
|
||||
selector = AngleSelector()
|
||||
selector.valueChanged.connect(lambda value, field=key: self._on_slider_changed(field, value))
|
||||
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, selector)
|
||||
|
||||
def _add_checkbox(self, key: str) -> None:
|
||||
checkbox = QCheckBox()
|
||||
checkbox.stateChanged.connect(lambda _state, field=key, widget=checkbox: self._on_checkbox_changed(field, widget.isChecked()))
|
||||
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, checkbox)
|
||||
|
||||
def _add_color(self, key: str) -> None:
|
||||
button = ColorButton("#4D7CFF" if key == "primary_color" else "#0E1630")
|
||||
button.colorChanged.connect(lambda value, field=key: self._on_color_changed(field, value))
|
||||
self._add_row(key, COMMON_PARAMETER_SPECS[key].label, button)
|
||||
|
||||
def _on_pattern_changed(self) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
self.controller.set_pattern(self.pattern_combo.currentData())
|
||||
|
||||
def _on_combo_changed(self, key: str) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
widget = self.widgets[key]
|
||||
self.controller.set_parameter(key, widget.currentData())
|
||||
|
||||
def _on_slider_changed(self, key: str, value: float) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
self.controller.set_parameter(key, value)
|
||||
|
||||
def _reset_slider(self, key: str, value: float) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
widget = self.widgets.get(key)
|
||||
if isinstance(widget, SliderField):
|
||||
widget.set_value(value)
|
||||
self.controller.set_parameter(key, value)
|
||||
|
||||
def _on_checkbox_changed(self, key: str, value: bool) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
self.controller.set_parameter(key, value)
|
||||
|
||||
def _on_color_changed(self, key: str, value: str) -> None:
|
||||
if self._updating:
|
||||
return
|
||||
self.controller.set_parameter(key, value)
|
||||
|
||||
def refresh_from_state(self) -> None:
|
||||
self._updating = True
|
||||
self.pattern_combo.setCurrentIndex(max(0, self.pattern_combo.findData(self.controller.pattern_id)))
|
||||
params = self.controller.params
|
||||
|
||||
for key, widget in self.widgets.items():
|
||||
value = getattr(params, key)
|
||||
if key == "palette":
|
||||
value = canonical_palette_name(str(value))
|
||||
if isinstance(widget, QComboBox):
|
||||
index = widget.findData(value)
|
||||
widget.setCurrentIndex(max(0, index))
|
||||
elif isinstance(widget, SliderField):
|
||||
widget.set_value(float(value))
|
||||
elif isinstance(widget, AngleSelector):
|
||||
widget.set_value(float(value))
|
||||
elif isinstance(widget, QCheckBox):
|
||||
widget.setChecked(bool(value))
|
||||
elif isinstance(widget, ColorButton):
|
||||
widget.set_color(str(value))
|
||||
|
||||
descriptor = next(
|
||||
(descriptor for descriptor in self.controller.available_patterns() if descriptor.pattern_id == self.controller.pattern_id),
|
||||
None,
|
||||
)
|
||||
supported = set(descriptor.supported_parameters) if descriptor is not None else set()
|
||||
for key, (label, widget) in self._rows.items():
|
||||
visible = key in supported
|
||||
label.setVisible(visible)
|
||||
widget.setVisible(visible)
|
||||
self._updating = False
|
||||
@@ -0,0 +1,75 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QHBoxLayout, QInputDialog, QListWidget, QListWidgetItem, QMessageBox, QPushButton, Qt, QVBoxLayout, QWidget
|
||||
from app.ui.section_panel import SectionPanel
|
||||
|
||||
|
||||
class PresetBrowser(QWidget):
|
||||
def __init__(self, controller, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
group = SectionPanel("Presets")
|
||||
group_layout = QVBoxLayout(group.body)
|
||||
group_layout.setContentsMargins(12, 12, 12, 12)
|
||||
group_layout.setSpacing(8)
|
||||
|
||||
self.list_widget = QListWidget()
|
||||
self.list_widget.itemDoubleClicked.connect(self._load_selected)
|
||||
group_layout.addWidget(self.list_widget, 1)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self.save_button = QPushButton("Save Current")
|
||||
self.load_button = QPushButton("Load")
|
||||
self.delete_button = QPushButton("Delete")
|
||||
button_row.addWidget(self.save_button)
|
||||
button_row.addWidget(self.load_button)
|
||||
button_row.addWidget(self.delete_button)
|
||||
group_layout.addLayout(button_row)
|
||||
|
||||
layout.addWidget(group)
|
||||
|
||||
self.save_button.clicked.connect(self._save_current)
|
||||
self.load_button.clicked.connect(self._load_selected)
|
||||
self.delete_button.clicked.connect(self._delete_selected)
|
||||
self.controller.presets_changed.connect(self.refresh)
|
||||
self.refresh()
|
||||
|
||||
def refresh(self) -> None:
|
||||
self.list_widget.clear()
|
||||
for preset in self.controller.available_presets():
|
||||
item = QListWidgetItem(preset.name)
|
||||
item.setToolTip(f"{preset.pattern_id}\nPalette: {preset.palette}")
|
||||
self.list_widget.addItem(item)
|
||||
|
||||
def _selected_name(self) -> str | None:
|
||||
item = self.list_widget.currentItem()
|
||||
return item.text() if item else None
|
||||
|
||||
def _save_current(self) -> None:
|
||||
name, ok = QInputDialog.getText(self, "Save Preset", "Preset name:")
|
||||
if ok and name.strip():
|
||||
self.controller.save_current_preset(name.strip())
|
||||
self.refresh()
|
||||
|
||||
def _load_selected(self, *_args) -> None:
|
||||
name = self._selected_name()
|
||||
if name:
|
||||
self.controller.apply_preset(name)
|
||||
|
||||
def _delete_selected(self) -> None:
|
||||
name = self._selected_name()
|
||||
if not name:
|
||||
return
|
||||
confirm = QMessageBox.question(
|
||||
self,
|
||||
"Delete Preset",
|
||||
f"Delete preset '{name}'?",
|
||||
QMessageBox.Yes | QMessageBox.No,
|
||||
)
|
||||
if confirm == QMessageBox.Yes:
|
||||
self.controller.delete_preset(name)
|
||||
self.refresh()
|
||||
@@ -0,0 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QLabel, Qt, QVBoxLayout, QWidget
|
||||
|
||||
from app.ui.preview_modes import PREVIEW_MODE_TILE
|
||||
from app.ui.preview_widget import PreviewWidget
|
||||
|
||||
|
||||
class FullscreenPreviewWindow(QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
controller,
|
||||
preview_mode: str = PREVIEW_MODE_TILE,
|
||||
scene_role: str = "live",
|
||||
technical_preview: bool | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Infinity Mirror Preview")
|
||||
self.setWindowFlag(Qt.Window, True)
|
||||
self.setAttribute(Qt.WA_DeleteOnClose, False)
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.preview_widget = PreviewWidget(
|
||||
controller,
|
||||
preview_mode=preview_mode,
|
||||
scene_role=scene_role,
|
||||
technical_preview=technical_preview,
|
||||
)
|
||||
self.preview_widget.tileClicked.connect(controller.set_selected_tile)
|
||||
layout.addWidget(self.preview_widget, 1)
|
||||
|
||||
hint = QLabel("Press F11 or Escape to leave fullscreen preview")
|
||||
hint.setAlignment(Qt.AlignCenter)
|
||||
hint.setStyleSheet("background: #2D2D30; color: #CCCCCC; padding: 8px; font-size: 12px; border-top: 1px solid #3C3C3C;")
|
||||
layout.addWidget(hint)
|
||||
|
||||
def set_preview_mode(self, mode: str) -> None:
|
||||
self.preview_widget.set_preview_mode(mode)
|
||||
|
||||
def set_technical_preview(self, enabled: bool) -> None:
|
||||
self.preview_widget.set_technical_preview(enabled)
|
||||
|
||||
def keyPressEvent(self, event) -> None:
|
||||
if event.key() in {Qt.Key_F11, Qt.Key_Escape}:
|
||||
self.hide()
|
||||
event.accept()
|
||||
return
|
||||
super().keyPressEvent(event)
|
||||
@@ -0,0 +1,68 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
from app.qt_compat import QRectF
|
||||
|
||||
from app.config.models import InfinityMirrorConfig, TileConfig
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class PreviewLayout:
|
||||
canvas_rect: QRectF
|
||||
tile_rects: dict[str, QRectF]
|
||||
|
||||
|
||||
def compute_preview_layout(widget_rect: QRectF, config: InfinityMirrorConfig) -> PreviewLayout:
|
||||
rows = config.logical_display.rows
|
||||
cols = config.logical_display.cols
|
||||
outer = QRectF(widget_rect).adjusted(28, 28, -28, -28)
|
||||
gap = min(22.0, max(10.0, min(outer.width() / 48.0, outer.height() / 22.0)))
|
||||
tile_aspect = tile_aspect_ratio(config)
|
||||
usable_width = max(1.0, outer.width() - gap * (cols - 1))
|
||||
usable_height = max(1.0, outer.height() - gap * (rows - 1))
|
||||
tile_width = usable_width / max(1, cols)
|
||||
tile_height = tile_width / tile_aspect
|
||||
if tile_height * rows > usable_height:
|
||||
tile_height = usable_height / max(1, rows)
|
||||
tile_width = tile_height * tile_aspect
|
||||
|
||||
shell_padding = max(18.0, min(tile_width, tile_height) * 0.16)
|
||||
grid_width = tile_width * cols + gap * (cols - 1)
|
||||
grid_height = tile_height * rows + gap * (rows - 1)
|
||||
left = outer.left() + (outer.width() - grid_width) / 2.0
|
||||
top = outer.top() + (outer.height() - grid_height) / 2.0
|
||||
canvas_rect = QRectF(left - shell_padding, top - shell_padding, grid_width + shell_padding * 2.0, grid_height + shell_padding * 2.0)
|
||||
|
||||
tile_rects: dict[str, QRectF] = {}
|
||||
for tile in config.sorted_tiles():
|
||||
x = left + (tile.col - 1) * (tile_width + gap)
|
||||
y = top + (tile.row - 1) * (tile_height + gap)
|
||||
tile_rects[tile.tile_id] = QRectF(x, y, tile_width, tile_height)
|
||||
return PreviewLayout(canvas_rect=canvas_rect, tile_rects=tile_rects)
|
||||
|
||||
|
||||
def tile_aspect_ratio(config: InfinityMirrorConfig) -> float:
|
||||
logical_display = config.logical_display
|
||||
tile_width = max(1.0, float(logical_display.tile_width))
|
||||
tile_height = max(1.0, float(logical_display.tile_height))
|
||||
return max(0.55, min(1.8, tile_width / tile_height))
|
||||
|
||||
|
||||
def segment_display_rect(tile: TileConfig, rect: QRectF) -> QRectF:
|
||||
tile_width = max(0.001, tile.x1 - tile.x0)
|
||||
tile_height = max(0.001, tile.y1 - tile.y0)
|
||||
aspect_ratio = max(0.5, min(1.8, tile_width / tile_height))
|
||||
base = min(rect.width(), rect.height())
|
||||
margin = max(6.0, base * 0.07)
|
||||
available_rect = rect.adjusted(margin, margin, -margin, -margin)
|
||||
|
||||
fitted_width = available_rect.width()
|
||||
fitted_height = fitted_width / aspect_ratio
|
||||
if fitted_height > available_rect.height():
|
||||
fitted_height = available_rect.height()
|
||||
fitted_width = fitted_height * aspect_ratio
|
||||
|
||||
left = available_rect.left() + (available_rect.width() - fitted_width) / 2.0
|
||||
top = available_rect.top() + (available_rect.height() - fitted_height) / 2.0
|
||||
return QRectF(left, top, fitted_width, fitted_height)
|
||||
@@ -0,0 +1,24 @@
|
||||
from __future__ import annotations
|
||||
|
||||
PREVIEW_MODE_TILE = "tile"
|
||||
PREVIEW_MODE_TECHNICAL = "technical"
|
||||
PREVIEW_MODE_LEDS = "leds"
|
||||
PREVIEW_MODES = (PREVIEW_MODE_TILE, PREVIEW_MODE_TECHNICAL, PREVIEW_MODE_LEDS)
|
||||
|
||||
|
||||
def normalize_preview_mode(mode: str | None) -> str:
|
||||
normalized = str(mode or PREVIEW_MODE_TILE).strip().lower()
|
||||
return normalized if normalized in PREVIEW_MODES else PREVIEW_MODE_TILE
|
||||
|
||||
|
||||
def preview_mode_flags(mode: str) -> dict[str, bool]:
|
||||
preview_mode = normalize_preview_mode(mode)
|
||||
return {
|
||||
"show_fill": preview_mode in {PREVIEW_MODE_TILE, PREVIEW_MODE_TECHNICAL},
|
||||
"show_labels": preview_mode in {PREVIEW_MODE_TILE, PREVIEW_MODE_TECHNICAL},
|
||||
"show_leds": preview_mode in {PREVIEW_MODE_TECHNICAL, PREVIEW_MODE_LEDS},
|
||||
"show_guides": preview_mode == PREVIEW_MODE_TECHNICAL,
|
||||
"show_direction": preview_mode == PREVIEW_MODE_TECHNICAL,
|
||||
"show_overlay_title": preview_mode == PREVIEW_MODE_TECHNICAL,
|
||||
"show_technical_meta": preview_mode == PREVIEW_MODE_TECHNICAL,
|
||||
}
|
||||
@@ -0,0 +1,274 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
from app.qt_compat import QColor, QFont, QLinearGradient, QPainter, QPainterPath, QPen, QPointF, QRectF, Qt
|
||||
|
||||
from app.config.models import SegmentConfig, TileConfig
|
||||
from app.core.geometry import segment_led_positions
|
||||
from app.core.types import PreviewFrame, RGBColor
|
||||
|
||||
from .preview_layout import PreviewLayout, segment_display_rect
|
||||
from .preview_modes import preview_mode_flags
|
||||
|
||||
|
||||
def _qcolor(color: RGBColor, alpha: float = 1.0) -> QColor:
|
||||
red, green, blue = color.to_8bit_tuple()
|
||||
qt_color = QColor(red, green, blue)
|
||||
qt_color.setAlphaF(max(0.0, min(1.0, alpha)))
|
||||
return qt_color
|
||||
|
||||
|
||||
def paint_empty_preview(painter: QPainter, rect: QRectF) -> None:
|
||||
painter.fillRect(rect, QColor("#1E1E1E"))
|
||||
painter.setPen(QColor("#8C8C8C"))
|
||||
painter.drawText(rect, Qt.AlignCenter, "Open a mapping to start the preview.")
|
||||
|
||||
|
||||
def paint_preview_scene(
|
||||
painter: QPainter,
|
||||
*,
|
||||
config,
|
||||
frame: PreviewFrame,
|
||||
preview_mode: str,
|
||||
selected_tile_id: str | None,
|
||||
target_rect: QRectF,
|
||||
layout: PreviewLayout,
|
||||
) -> None:
|
||||
flags = preview_mode_flags(preview_mode)
|
||||
background = QLinearGradient(0, 0, target_rect.width(), target_rect.height())
|
||||
background.setColorAt(0.0, _qcolor(frame.background_start))
|
||||
background.setColorAt(1.0, _qcolor(frame.background_end))
|
||||
painter.fillRect(target_rect, background)
|
||||
|
||||
_draw_canvas_shell(painter, layout.canvas_rect)
|
||||
for tile in config.sorted_tiles():
|
||||
tile_frame = frame.tiles.get(tile.tile_id)
|
||||
tile_rect = layout.tile_rects[tile.tile_id]
|
||||
_draw_tile(
|
||||
painter,
|
||||
tile=tile,
|
||||
tile_frame=tile_frame,
|
||||
rect=tile_rect,
|
||||
flags=flags,
|
||||
selected_tile_id=selected_tile_id,
|
||||
)
|
||||
|
||||
if flags["show_overlay_title"]:
|
||||
painter.setPen(QColor(204, 204, 204, 140))
|
||||
painter.drawText(
|
||||
target_rect.adjusted(24, 18, -24, -18),
|
||||
Qt.AlignTop | Qt.AlignRight,
|
||||
"Technical Preview",
|
||||
)
|
||||
|
||||
|
||||
def _draw_canvas_shell(painter: QPainter, rect: QRectF) -> None:
|
||||
path = QPainterPath()
|
||||
path.addRoundedRect(rect, 8, 8)
|
||||
painter.fillPath(path, QColor("#252526"))
|
||||
painter.setPen(QPen(QColor("#3C3C3C"), 1.0))
|
||||
painter.drawPath(path)
|
||||
|
||||
|
||||
def _draw_tile(
|
||||
painter: QPainter,
|
||||
*,
|
||||
tile: TileConfig,
|
||||
tile_frame,
|
||||
rect: QRectF,
|
||||
flags: dict[str, bool],
|
||||
selected_tile_id: str | None,
|
||||
) -> None:
|
||||
if tile_frame is None:
|
||||
return
|
||||
|
||||
base = min(rect.width(), rect.height())
|
||||
rounding = max(4.0, base * 0.045)
|
||||
fill_color = _qcolor(tile_frame.fill_color)
|
||||
rim_color = _qcolor(tile_frame.rim_color)
|
||||
diagonal_split = tile_frame.metadata.get("diagonal_split")
|
||||
|
||||
tile_path = QPainterPath()
|
||||
tile_path.addRoundedRect(rect, rounding, rounding)
|
||||
if flags["show_fill"] and isinstance(diagonal_split, dict):
|
||||
_draw_diagonal_split_fill(painter, tile_path, rect, diagonal_split)
|
||||
elif flags["show_fill"]:
|
||||
painter.fillPath(tile_path, fill_color)
|
||||
else:
|
||||
painter.fillPath(tile_path, QColor("#090B12"))
|
||||
|
||||
if flags["show_fill"]:
|
||||
highlight = QLinearGradient(rect.topLeft(), rect.bottomLeft())
|
||||
highlight.setColorAt(0.0, QColor(255, 255, 255, 26))
|
||||
highlight.setColorAt(0.12, QColor(255, 255, 255, 10))
|
||||
highlight.setColorAt(1.0, QColor(0, 0, 0, 0))
|
||||
painter.fillPath(tile_path, highlight)
|
||||
|
||||
outline_color = rim_color if flags["show_fill"] else QColor(255, 255, 255, 32)
|
||||
painter.setPen(QPen(outline_color, 1.2 if flags["show_leds"] else 1.0))
|
||||
painter.drawPath(tile_path)
|
||||
|
||||
if flags["show_fill"]:
|
||||
inner_rect = rect.adjusted(rect.width() * 0.08, rect.height() * 0.08, -rect.width() * 0.08, -rect.height() * 0.08)
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 14), 1.0))
|
||||
painter.drawRoundedRect(inner_rect, rounding * 0.66, rounding * 0.66)
|
||||
|
||||
if not tile.enabled:
|
||||
painter.fillPath(tile_path, QColor(0, 0, 0, 125))
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 36), 1.0, Qt.DashLine))
|
||||
painter.drawRoundedRect(rect.adjusted(6, 6, -6, -6), rounding * 0.8, rounding * 0.8)
|
||||
|
||||
if selected_tile_id == tile.tile_id:
|
||||
painter.setPen(QPen(QColor("#007ACC"), 2.0))
|
||||
painter.drawRoundedRect(rect.adjusted(-3, -3, 3, 3), rounding + 2, rounding + 2)
|
||||
|
||||
if flags["show_labels"]:
|
||||
_draw_labels(painter, tile, tile_frame, rect, technical_meta=flags["show_technical_meta"])
|
||||
|
||||
if flags["show_leds"]:
|
||||
_draw_segment_preview(
|
||||
painter,
|
||||
tile,
|
||||
tile_frame,
|
||||
rect,
|
||||
show_guides=flags["show_guides"],
|
||||
show_direction=flags["show_direction"],
|
||||
)
|
||||
|
||||
|
||||
def _draw_diagonal_split_fill(painter: QPainter, tile_path: QPainterPath, rect: QRectF, diagonal_split: dict[str, object]) -> None:
|
||||
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):
|
||||
painter.fillPath(tile_path, QColor("#000000"))
|
||||
return
|
||||
|
||||
painter.save()
|
||||
painter.setClipPath(tile_path)
|
||||
orientation = str(diagonal_split.get("orientation", "slash"))
|
||||
|
||||
first = QPainterPath()
|
||||
second = QPainterPath()
|
||||
if orientation == "backslash":
|
||||
first.moveTo(rect.topLeft())
|
||||
first.lineTo(rect.topRight())
|
||||
first.lineTo(rect.bottomRight())
|
||||
first.closeSubpath()
|
||||
|
||||
second.moveTo(rect.topLeft())
|
||||
second.lineTo(rect.bottomLeft())
|
||||
second.lineTo(rect.bottomRight())
|
||||
second.closeSubpath()
|
||||
else:
|
||||
first.moveTo(rect.topLeft())
|
||||
first.lineTo(rect.topRight())
|
||||
first.lineTo(rect.bottomLeft())
|
||||
first.closeSubpath()
|
||||
|
||||
second.moveTo(rect.topRight())
|
||||
second.lineTo(rect.bottomRight())
|
||||
second.lineTo(rect.bottomLeft())
|
||||
second.closeSubpath()
|
||||
|
||||
painter.fillPath(first, _qcolor(color_a))
|
||||
painter.fillPath(second, _qcolor(color_b))
|
||||
painter.restore()
|
||||
|
||||
|
||||
def _draw_labels(painter: QPainter, tile: TileConfig, tile_frame, rect: QRectF, technical_meta: bool = False) -> None:
|
||||
painter.save()
|
||||
base = min(rect.width(), rect.height())
|
||||
horizontal_padding = max(12.0, rect.width() * 0.08)
|
||||
top_padding = max(10.0, rect.height() * 0.07)
|
||||
bottom_padding = max(12.0, rect.height() * 0.08)
|
||||
|
||||
font = QFont()
|
||||
font.setPointSizeF(max(14.0, base * 0.105))
|
||||
font.setWeight(QFont.DemiBold)
|
||||
painter.setFont(font)
|
||||
painter.setPen(_qcolor(tile_frame.label_color, 0.92))
|
||||
title_rect = rect.adjusted(horizontal_padding, top_padding, -horizontal_padding, -rect.height() * 0.52)
|
||||
painter.drawText(title_rect, Qt.AlignLeft | Qt.AlignTop | Qt.TextWordWrap, tile.tile_id)
|
||||
|
||||
meta_font = QFont(font)
|
||||
meta_font.setPointSizeF(max(11.5, base * (0.07 if technical_meta else 0.082)))
|
||||
meta_font.setWeight(QFont.Normal)
|
||||
painter.setFont(meta_font)
|
||||
text = f"R{tile.row} C{tile.col}"
|
||||
if technical_meta:
|
||||
text = f"{tile.screen_name or tile.controller_ip}\nU{tile.universe} S{tile.subnet} {tile.led_total} LEDs"
|
||||
painter.setPen(QColor(235, 244, 249, 165))
|
||||
meta_rect = rect.adjusted(horizontal_padding, rect.height() * 0.56, -horizontal_padding, -bottom_padding)
|
||||
painter.drawText(meta_rect, Qt.AlignLeft | Qt.AlignBottom | Qt.TextWordWrap, text)
|
||||
painter.restore()
|
||||
|
||||
|
||||
def _draw_segment_preview(
|
||||
painter: QPainter,
|
||||
tile: TileConfig,
|
||||
tile_frame,
|
||||
rect: QRectF,
|
||||
*,
|
||||
show_guides: bool,
|
||||
show_direction: bool,
|
||||
) -> None:
|
||||
painter.save()
|
||||
led_radius = max(2.0, min(rect.width(), rect.height()) / 64.0)
|
||||
guide_pen = QPen(QColor(220, 228, 236, 46), max(0.9, led_radius * 0.55))
|
||||
guide_pen.setCapStyle(Qt.RoundCap)
|
||||
guide_pen.setJoinStyle(Qt.RoundJoin)
|
||||
for segment in tile.segments:
|
||||
points = _segment_points(tile, segment, rect)
|
||||
colors = tile_frame.led_pixels.get(segment.name, [])
|
||||
if show_guides and len(points) >= 2:
|
||||
painter.setPen(guide_pen)
|
||||
for start, end in zip(points, points[1:]):
|
||||
painter.drawLine(start, end)
|
||||
|
||||
painter.setPen(Qt.NoPen)
|
||||
for index, point in enumerate(points):
|
||||
color = colors[index] if index < len(colors) else tile_frame.rim_color
|
||||
if color.to_8bit_tuple() == (0, 0, 0):
|
||||
continue
|
||||
painter.setBrush(_qcolor(color, 0.94))
|
||||
painter.drawEllipse(point, led_radius, led_radius)
|
||||
|
||||
if show_direction and points:
|
||||
_draw_direction_arrow(painter, points, segment)
|
||||
|
||||
painter.restore()
|
||||
|
||||
|
||||
def _segment_points(tile: TileConfig, segment: SegmentConfig, rect: QRectF) -> list[QPointF]:
|
||||
display_rect = segment_display_rect(tile, rect)
|
||||
inset = max(2.0, min(display_rect.width(), display_rect.height()) * 0.02)
|
||||
insets = (
|
||||
inset / max(1.0, display_rect.width()),
|
||||
inset / max(1.0, display_rect.height()),
|
||||
)
|
||||
return [
|
||||
QPointF(display_rect.left() + x_pos * display_rect.width(), display_rect.top() + y_pos * display_rect.height())
|
||||
for x_pos, y_pos in segment_led_positions(tile, segment, insets=insets)
|
||||
]
|
||||
|
||||
|
||||
def _draw_direction_arrow(painter: QPainter, points: list[QPointF], segment: SegmentConfig) -> None:
|
||||
if len(points) < 2:
|
||||
return
|
||||
start = points[0]
|
||||
end = points[-1]
|
||||
|
||||
mid = QPointF((start.x() + end.x()) / 2.0, (start.y() + end.y()) / 2.0)
|
||||
dx = end.x() - start.x()
|
||||
dy = end.y() - start.y()
|
||||
length = math.hypot(dx, dy) or 1.0
|
||||
ux, uy = dx / length, dy / length
|
||||
arrow_len = 14.0
|
||||
left = QPointF(mid.x() - ux * arrow_len + -uy * arrow_len * 0.5, mid.y() - uy * arrow_len + ux * arrow_len * 0.5)
|
||||
right = QPointF(mid.x() - ux * arrow_len - -uy * arrow_len * 0.5, mid.y() - uy * arrow_len - ux * arrow_len * 0.5)
|
||||
|
||||
painter.setPen(QPen(QColor(255, 255, 255, 85), 1.0))
|
||||
painter.drawLine(start, end)
|
||||
painter.drawLine(mid, left)
|
||||
painter.drawLine(mid, right)
|
||||
@@ -0,0 +1,118 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QPainter, QPointF, QRectF, Qt, Signal, QWidget, event_posf
|
||||
|
||||
from app.config.models import SegmentConfig, TileConfig
|
||||
from app.core.geometry import segment_led_positions, segment_side
|
||||
from app.core.types import PreviewFrame
|
||||
|
||||
from .preview_layout import compute_preview_layout, segment_display_rect, tile_aspect_ratio
|
||||
from .preview_modes import (
|
||||
PREVIEW_MODE_LEDS,
|
||||
PREVIEW_MODE_TECHNICAL,
|
||||
PREVIEW_MODE_TILE,
|
||||
normalize_preview_mode,
|
||||
preview_mode_flags,
|
||||
)
|
||||
from .preview_painter import paint_empty_preview, paint_preview_scene
|
||||
|
||||
|
||||
class PreviewWidget(QWidget):
|
||||
tileClicked = Signal(str)
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
controller,
|
||||
preview_mode: str = PREVIEW_MODE_TILE,
|
||||
scene_role: str = "live",
|
||||
technical_preview: bool | None = None,
|
||||
parent: QWidget | None = None,
|
||||
) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
self.scene_role = "next" if str(scene_role).strip().lower() == "next" else "live"
|
||||
if technical_preview is not None:
|
||||
preview_mode = PREVIEW_MODE_TECHNICAL if technical_preview else PREVIEW_MODE_TILE
|
||||
self.preview_mode = normalize_preview_mode(preview_mode)
|
||||
self.technical_preview = self.preview_mode == PREVIEW_MODE_TECHNICAL
|
||||
self.current_frame: PreviewFrame | None = self.controller.preview_frame_for(self.scene_role)
|
||||
self._tile_rects: dict[str, QRectF] = {}
|
||||
|
||||
self.setMinimumSize(640, 360)
|
||||
self.setMouseTracking(True)
|
||||
|
||||
if self.scene_role == "next":
|
||||
self.controller.next_frame_ready.connect(self._on_frame_ready)
|
||||
else:
|
||||
self.controller.frame_ready.connect(self._on_frame_ready)
|
||||
self.controller.config_changed.connect(self.update)
|
||||
self.controller.state_changed.connect(self.update)
|
||||
|
||||
def set_preview_mode(self, mode: str) -> None:
|
||||
self.preview_mode = normalize_preview_mode(mode)
|
||||
self.technical_preview = self.preview_mode == PREVIEW_MODE_TECHNICAL
|
||||
self.update()
|
||||
|
||||
def set_technical_preview(self, enabled: bool) -> None:
|
||||
self.set_preview_mode(PREVIEW_MODE_TECHNICAL if enabled else PREVIEW_MODE_TILE)
|
||||
|
||||
def _mode_flags(self) -> dict[str, bool]:
|
||||
return preview_mode_flags(self.preview_mode)
|
||||
|
||||
def _on_frame_ready(self, frame: PreviewFrame) -> None:
|
||||
self.current_frame = frame
|
||||
self.update()
|
||||
|
||||
def mousePressEvent(self, event) -> None:
|
||||
point = event_posf(event)
|
||||
for tile_id, rect in self._tile_rects.items():
|
||||
if rect.contains(point):
|
||||
self.tileClicked.emit(tile_id)
|
||||
break
|
||||
super().mousePressEvent(event)
|
||||
|
||||
def paintEvent(self, event) -> None:
|
||||
painter = QPainter(self)
|
||||
painter.setRenderHint(QPainter.Antialiasing, True)
|
||||
painter.setRenderHint(QPainter.TextAntialiasing, True)
|
||||
|
||||
frame = self.current_frame
|
||||
if frame is None or not self.controller.config.tiles:
|
||||
paint_empty_preview(painter, QRectF(self.rect()))
|
||||
return
|
||||
|
||||
layout = compute_preview_layout(QRectF(self.rect()), self.controller.config)
|
||||
self._tile_rects = layout.tile_rects
|
||||
paint_preview_scene(
|
||||
painter,
|
||||
config=self.controller.config,
|
||||
frame=frame,
|
||||
preview_mode=self.preview_mode,
|
||||
selected_tile_id=self.controller.selected_tile_id,
|
||||
target_rect=QRectF(self.rect()),
|
||||
layout=layout,
|
||||
)
|
||||
|
||||
def _compute_layout(self) -> tuple[QRectF, dict[str, QRectF]]:
|
||||
layout = compute_preview_layout(QRectF(self.rect()), self.controller.config)
|
||||
return layout.canvas_rect, layout.tile_rects
|
||||
|
||||
def _tile_aspect_ratio(self) -> float:
|
||||
return tile_aspect_ratio(self.controller.config)
|
||||
|
||||
def _segment_display_rect(self, tile: TileConfig, rect: QRectF) -> QRectF:
|
||||
return segment_display_rect(tile, rect)
|
||||
|
||||
def _segment_side(self, tile: TileConfig, segment: SegmentConfig) -> str | None:
|
||||
return segment_side(tile, segment)
|
||||
|
||||
def _segment_points_for_side(self, tile: TileConfig, segment: SegmentConfig, rect: QRectF) -> list[QPointF]:
|
||||
inset = max(2.0, min(rect.width(), rect.height()) * 0.02)
|
||||
insets = (
|
||||
inset / max(1.0, rect.width()),
|
||||
inset / max(1.0, rect.height()),
|
||||
)
|
||||
return [
|
||||
QPointF(rect.left() + x_pos * rect.width(), rect.top() + y_pos * rect.height())
|
||||
for x_pos, y_pos in segment_led_positions(tile, segment, insets=insets)
|
||||
]
|
||||
@@ -0,0 +1,85 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from app.ui.preview_fullscreen import FullscreenPreviewWindow
|
||||
from app.ui.preview_modes import PREVIEW_MODE_TILE, normalize_preview_mode
|
||||
from app.ui.preview_widget import PreviewWidget
|
||||
|
||||
|
||||
class ScenePreviewArea(QWidget):
|
||||
def __init__(self, controller, preview_mode: str = PREVIEW_MODE_TILE, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.controller = controller
|
||||
self.preview_mode = normalize_preview_mode(preview_mode)
|
||||
|
||||
self.fullscreen_preview = FullscreenPreviewWindow(self.controller, preview_mode=self.preview_mode, scene_role="live")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 12, 0, 0)
|
||||
layout.setSpacing(14)
|
||||
|
||||
self.preview_title = QLabel("Preview")
|
||||
self.preview_title.setStyleSheet("font-size: 12px; font-weight: 600; color: #CCCCCC; padding: 0 0 6px 2px;")
|
||||
layout.addWidget(self.preview_title)
|
||||
|
||||
self.single_preview_container = QWidget()
|
||||
single_layout = QVBoxLayout(self.single_preview_container)
|
||||
single_layout.setContentsMargins(0, 0, 0, 0)
|
||||
single_layout.setSpacing(0)
|
||||
self.preview_widget = PreviewWidget(self.controller, preview_mode=self.preview_mode, scene_role="live")
|
||||
self.preview_widget.tileClicked.connect(self.controller.set_selected_tile)
|
||||
single_layout.addWidget(self.preview_widget, 1)
|
||||
layout.addWidget(self.single_preview_container, 1)
|
||||
|
||||
self.foh_preview_container = QWidget()
|
||||
foh_layout = QVBoxLayout(self.foh_preview_container)
|
||||
foh_layout.setContentsMargins(0, 0, 0, 0)
|
||||
foh_layout.setSpacing(12)
|
||||
|
||||
self.live_preview_widget = PreviewWidget(self.controller, preview_mode=self.preview_mode, scene_role="live")
|
||||
self.live_preview_widget.tileClicked.connect(self.controller.set_selected_tile)
|
||||
live_panel, self.live_preview_label = self._build_scene_preview_panel("Live", self.live_preview_widget)
|
||||
foh_layout.addWidget(live_panel, 1)
|
||||
|
||||
self.next_preview_widget = PreviewWidget(self.controller, preview_mode=self.preview_mode, scene_role="next")
|
||||
self.next_preview_widget.tileClicked.connect(self.controller.set_selected_tile)
|
||||
next_panel, self.next_preview_label = self._build_scene_preview_panel("Next", self.next_preview_widget)
|
||||
foh_layout.addWidget(next_panel, 1)
|
||||
|
||||
layout.addWidget(self.foh_preview_container, 1)
|
||||
|
||||
def set_preview_mode(self, mode: str) -> None:
|
||||
self.preview_mode = normalize_preview_mode(mode)
|
||||
self.preview_widget.set_preview_mode(self.preview_mode)
|
||||
self.live_preview_widget.set_preview_mode(self.preview_mode)
|
||||
self.next_preview_widget.set_preview_mode(self.preview_mode)
|
||||
self.fullscreen_preview.set_preview_mode(self.preview_mode)
|
||||
|
||||
def set_foh_mode(self, enabled: bool) -> None:
|
||||
self.single_preview_container.setVisible(not enabled)
|
||||
self.foh_preview_container.setVisible(enabled)
|
||||
|
||||
def set_scene_labels(self, live_label: str, next_label: str) -> None:
|
||||
self.live_preview_label.setText(live_label)
|
||||
self.next_preview_label.setText(next_label)
|
||||
|
||||
def toggle_fullscreen(self) -> None:
|
||||
if self.fullscreen_preview.isVisible():
|
||||
self.fullscreen_preview.hide()
|
||||
else:
|
||||
self.fullscreen_preview.showFullScreen()
|
||||
|
||||
def _build_scene_preview_panel(self, title: str, preview_widget: PreviewWidget) -> tuple[QWidget, QLabel]:
|
||||
panel = QWidget()
|
||||
layout = QVBoxLayout(panel)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(6)
|
||||
|
||||
title_label = QLabel(title)
|
||||
title_label.setStyleSheet("font-size: 13px; font-weight: 600; color: #E6E6E6; padding-left: 2px;")
|
||||
layout.addWidget(title_label)
|
||||
|
||||
preview_widget.setMinimumHeight(220)
|
||||
layout.addWidget(preview_widget, 1)
|
||||
return panel, title_label
|
||||
@@ -0,0 +1,36 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QHBoxLayout, QLabel, Qt, QVBoxLayout, QWidget
|
||||
|
||||
|
||||
class SectionPanel(QWidget):
|
||||
def __init__(self, title: str, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setObjectName("sectionPanel")
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
layout.setSpacing(0)
|
||||
|
||||
self.header_widget = QWidget(self)
|
||||
self.header_widget.setObjectName("sectionHeader")
|
||||
self.header_widget.setFixedHeight(40)
|
||||
header_layout = QHBoxLayout(self.header_widget)
|
||||
header_layout.setContentsMargins(12, 0, 12, 0)
|
||||
header_layout.setSpacing(0)
|
||||
|
||||
self.title_label = QLabel(self.header_widget)
|
||||
self.title_label.setObjectName("sectionHeaderLabel")
|
||||
self.title_label.setAlignment(Qt.AlignLeft | Qt.AlignVCenter)
|
||||
header_layout.addWidget(self.title_label, 1)
|
||||
|
||||
self.body = QWidget(self)
|
||||
self.body.setObjectName("sectionBody")
|
||||
|
||||
layout.addWidget(self.header_widget)
|
||||
layout.addWidget(self.body)
|
||||
|
||||
self.setTitle(title)
|
||||
|
||||
def setTitle(self, title: str) -> None:
|
||||
self.title_label.setText(title)
|
||||
@@ -0,0 +1,833 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import threading
|
||||
|
||||
from app.qt_compat import QComboBox, QDialog, QDialogButtonBox, QFormLayout, QGroupBox, QHBoxLayout, QHeaderView, QLabel, QLineEdit, QListWidget, QMessageBox, QObject, QPushButton, QPlainTextEdit, QSpinBox, QTabWidget, QTableWidget, QTableWidgetItem, Qt, QVBoxLayout, QWidget, Signal
|
||||
|
||||
from app.config.device_assignment import assign_device_to_tile, find_tile_for_device, tile_matches_device
|
||||
from app.config.models import InfinityMirrorConfig, SegmentConfig
|
||||
from app.config.xml_mapping import MappingValidationError, config_to_xml_string, load_config_from_string, save_config, validate_config
|
||||
from app.network.wled import DiscoveredWledDevice, build_scan_hosts, discover_wled_devices, identify_wled_device
|
||||
from app.ui.mapping_assignment_preview import MappingAssignmentPreview
|
||||
|
||||
|
||||
class NetworkScanWorker(QObject):
|
||||
progress = Signal(int, int, object)
|
||||
finished = Signal(object, str, int)
|
||||
|
||||
def __init__(self, config: InfinityMirrorConfig) -> None:
|
||||
super().__init__()
|
||||
self._config = config.clone()
|
||||
|
||||
def start(self) -> None:
|
||||
thread = threading.Thread(target=self._run, name="InfinityMirrorNetworkScan", daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _run(self) -> None:
|
||||
try:
|
||||
hosts = build_scan_hosts(self._config)
|
||||
devices = discover_wled_devices(hosts, progress_callback=self._emit_progress)
|
||||
self.finished.emit(devices, "", len(hosts))
|
||||
except Exception as exc:
|
||||
self.finished.emit([], str(exc), 0)
|
||||
|
||||
def _emit_progress(self, completed: int, total: int, device: DiscoveredWledDevice | None) -> None:
|
||||
self.progress.emit(completed, total, device)
|
||||
|
||||
|
||||
class DeviceIdentifyWorker(QObject):
|
||||
finished = Signal(object, str)
|
||||
|
||||
def __init__(self, device: DiscoveredWledDevice) -> None:
|
||||
super().__init__()
|
||||
self._device = device
|
||||
|
||||
def start(self) -> None:
|
||||
thread = threading.Thread(target=self._run, name=f"WledIdentify-{self._device.ip_address}", daemon=True)
|
||||
thread.start()
|
||||
|
||||
def _run(self) -> None:
|
||||
try:
|
||||
identify_wled_device(self._device.ip_address, led_count=self._device.led_count)
|
||||
except Exception as exc:
|
||||
self.finished.emit(self._device, str(exc))
|
||||
return
|
||||
self.finished.emit(self._device, "")
|
||||
|
||||
|
||||
class SettingsDialog(QDialog):
|
||||
TILE_COLUMNS = [
|
||||
("tile_id", "Tile ID"),
|
||||
("row", "Row"),
|
||||
("col", "Col"),
|
||||
("screen_name", "Screen Name"),
|
||||
("controller_ip", "Controller IP"),
|
||||
("controller_name", "Controller Name"),
|
||||
("controller_host", "Controller Host"),
|
||||
("controller_mac", "Controller MAC"),
|
||||
("subnet", "Subnet"),
|
||||
("universe", "Universe"),
|
||||
("led_total", "LED Count"),
|
||||
("brightness_factor", "Brightness"),
|
||||
("enabled", "Enabled"),
|
||||
]
|
||||
SEGMENT_COLUMNS = [
|
||||
("name", "Name"),
|
||||
("side", "Side"),
|
||||
("start_channel", "Start Ch"),
|
||||
("led_count", "LED Count"),
|
||||
("orientation_rad", "Orientation"),
|
||||
("x0", "x0"),
|
||||
("y0", "y0"),
|
||||
("x1", "x1"),
|
||||
("y1", "y1"),
|
||||
("reverse", "Reverse"),
|
||||
]
|
||||
DEVICE_COLUMNS = [
|
||||
"IP Address",
|
||||
"Host / mDNS",
|
||||
"WLED Name",
|
||||
"MAC Address",
|
||||
"Status",
|
||||
"Map",
|
||||
"Identify",
|
||||
]
|
||||
|
||||
def __init__(self, config: InfinityMirrorConfig, controller=None, parent: QWidget | None = None) -> None:
|
||||
super().__init__(parent)
|
||||
self.setWindowTitle("Mapping Settings")
|
||||
self.resize(1320, 900)
|
||||
|
||||
self.controller = controller
|
||||
self.working_config = config.clone()
|
||||
self.result_config: InfinityMirrorConfig | None = None
|
||||
self._active_segment_tile_id: str | None = None
|
||||
self._device_table_refreshing = False
|
||||
self._active_device_key: str | None = None
|
||||
self._selected_assignment_tile_id: str | None = None
|
||||
self._scan_worker: NetworkScanWorker | None = None
|
||||
self._identify_workers: set[DeviceIdentifyWorker] = set()
|
||||
self.discovered_devices: list[DiscoveredWledDevice] = []
|
||||
self._last_scan_host_count = 0
|
||||
|
||||
root_layout = QVBoxLayout(self)
|
||||
|
||||
header_group = QGroupBox("Mapping")
|
||||
header_form = QFormLayout(header_group)
|
||||
self.name_edit = QLineEdit(self.working_config.name)
|
||||
self.rows_spin = QSpinBox()
|
||||
self.rows_spin.setRange(1, 24)
|
||||
self.rows_spin.setValue(self.working_config.logical_display.rows)
|
||||
self.cols_spin = QSpinBox()
|
||||
self.cols_spin.setRange(1, 24)
|
||||
self.cols_spin.setValue(self.working_config.logical_display.cols)
|
||||
header_form.addRow("Name", self.name_edit)
|
||||
header_form.addRow("Rows", self.rows_spin)
|
||||
header_form.addRow("Cols", self.cols_spin)
|
||||
root_layout.addWidget(header_group)
|
||||
|
||||
self.tabs = QTabWidget()
|
||||
self.tabs.currentChanged.connect(self._handle_tab_changed)
|
||||
root_layout.addWidget(self.tabs, 1)
|
||||
|
||||
self.tiles_tab = QWidget()
|
||||
self.network_tab = QWidget()
|
||||
self.segments_tab = QWidget()
|
||||
self.raw_tab = QWidget()
|
||||
self.tabs.addTab(self.tiles_tab, "Tiles")
|
||||
self.tabs.addTab(self.network_tab, "Network Mapping")
|
||||
self.tabs.addTab(self.segments_tab, "Segments")
|
||||
self.tabs.addTab(self.raw_tab, "Raw XML")
|
||||
|
||||
self._build_tiles_tab()
|
||||
self._build_network_tab()
|
||||
self._build_segments_tab()
|
||||
self._build_raw_tab()
|
||||
self._populate_tiles_table()
|
||||
self._rebuild_segment_tile_combo()
|
||||
self._refresh_raw_xml()
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
button_box = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
|
||||
button_box.accepted.connect(self._save_and_accept)
|
||||
button_box.rejected.connect(self.reject)
|
||||
root_layout.addWidget(button_box)
|
||||
|
||||
def _build_tiles_tab(self) -> None:
|
||||
layout = QVBoxLayout(self.tiles_tab)
|
||||
self.tiles_table = QTableWidget(0, len(self.TILE_COLUMNS))
|
||||
self.tiles_table.setHorizontalHeaderLabels([label for _, label in self.TILE_COLUMNS])
|
||||
self.tiles_table.verticalHeader().setVisible(False)
|
||||
self.tiles_table.setAlternatingRowColors(True)
|
||||
self.tiles_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.tiles_table.setSortingEnabled(False)
|
||||
layout.addWidget(self.tiles_table)
|
||||
|
||||
def _build_network_tab(self) -> None:
|
||||
layout = QVBoxLayout(self.network_tab)
|
||||
layout.setSpacing(12)
|
||||
|
||||
intro = QLabel(
|
||||
"Scan the local network for WLED controllers, flash one device at a time, "
|
||||
"then click the matching physical tile to save the assignment."
|
||||
)
|
||||
intro.setWordWrap(True)
|
||||
layout.addWidget(intro)
|
||||
|
||||
hint = QLabel(
|
||||
"Assignments save immediately to the open mapping file when the current mapping validates. "
|
||||
"If the mapping cannot be saved yet, the assignment stays in this dialog and you will see a warning."
|
||||
)
|
||||
hint.setWordWrap(True)
|
||||
hint.setStyleSheet("color: #A8B3C7;")
|
||||
layout.addWidget(hint)
|
||||
|
||||
content_row = QHBoxLayout()
|
||||
content_row.setSpacing(12)
|
||||
layout.addLayout(content_row, 1)
|
||||
|
||||
preview_group = QGroupBox("Mapping Preview")
|
||||
preview_layout = QVBoxLayout(preview_group)
|
||||
preview_layout.setSpacing(8)
|
||||
self.assignment_workflow_label = QLabel("Scan the network to begin assisted mapping.")
|
||||
self.assignment_workflow_label.setWordWrap(True)
|
||||
preview_layout.addWidget(self.assignment_workflow_label)
|
||||
self.mapping_preview = MappingAssignmentPreview()
|
||||
self.mapping_preview.tileClicked.connect(self._handle_mapping_preview_click)
|
||||
preview_layout.addWidget(self.mapping_preview, 1)
|
||||
content_row.addWidget(preview_group, 1)
|
||||
|
||||
device_group = QGroupBox("Discovered WLED Devices")
|
||||
device_layout = QVBoxLayout(device_group)
|
||||
device_layout.setSpacing(8)
|
||||
|
||||
scan_row = QHBoxLayout()
|
||||
self.scan_button = QPushButton("Scan Network")
|
||||
self.scan_button.clicked.connect(self._scan_network)
|
||||
scan_row.addWidget(self.scan_button)
|
||||
self.scan_status_label = QLabel("Not scanned yet.")
|
||||
self.scan_status_label.setWordWrap(True)
|
||||
scan_row.addWidget(self.scan_status_label, 1)
|
||||
device_layout.addLayout(scan_row)
|
||||
|
||||
self.mapping_summary_label = QLabel("No devices discovered.")
|
||||
self.mapping_summary_label.setWordWrap(True)
|
||||
self.mapping_summary_label.setStyleSheet("color: #C7D2E1;")
|
||||
device_layout.addWidget(self.mapping_summary_label)
|
||||
|
||||
self.discovered_devices_table = QTableWidget(0, len(self.DEVICE_COLUMNS))
|
||||
self.discovered_devices_table.setHorizontalHeaderLabels(self.DEVICE_COLUMNS)
|
||||
self.discovered_devices_table.verticalHeader().setVisible(False)
|
||||
self.discovered_devices_table.setAlternatingRowColors(True)
|
||||
self.discovered_devices_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
self.discovered_devices_table.setSortingEnabled(False)
|
||||
self.discovered_devices_table.itemSelectionChanged.connect(self._on_discovered_device_selection_changed)
|
||||
header = self.discovered_devices_table.horizontalHeader()
|
||||
header.setSectionResizeMode(0, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(1, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(2, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(3, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(4, QHeaderView.Stretch)
|
||||
header.setSectionResizeMode(5, QHeaderView.ResizeToContents)
|
||||
header.setSectionResizeMode(6, QHeaderView.ResizeToContents)
|
||||
device_layout.addWidget(self.discovered_devices_table, 1)
|
||||
|
||||
self.assignment_feedback_label = QLabel("Select a device or press Identify, then click the matching tile.")
|
||||
self.assignment_feedback_label.setWordWrap(True)
|
||||
self.assignment_feedback_label.setStyleSheet("color: #A8B3C7;")
|
||||
device_layout.addWidget(self.assignment_feedback_label)
|
||||
|
||||
content_row.addWidget(device_group, 1)
|
||||
|
||||
def _build_segments_tab(self) -> None:
|
||||
layout = QVBoxLayout(self.segments_tab)
|
||||
|
||||
top_row = QHBoxLayout()
|
||||
top_row.addWidget(QLabel("Tile"))
|
||||
self.segment_tile_combo = QComboBox()
|
||||
self.segment_tile_combo.currentIndexChanged.connect(self._on_segment_tile_changed)
|
||||
top_row.addWidget(self.segment_tile_combo, 1)
|
||||
self.add_segment_button = QPushButton("Add Segment")
|
||||
self.remove_segment_button = QPushButton("Remove Selected")
|
||||
top_row.addWidget(self.add_segment_button)
|
||||
top_row.addWidget(self.remove_segment_button)
|
||||
layout.addLayout(top_row)
|
||||
|
||||
self.segments_table = QTableWidget(0, len(self.SEGMENT_COLUMNS))
|
||||
self.segments_table.setHorizontalHeaderLabels([label for _, label in self.SEGMENT_COLUMNS])
|
||||
self.segments_table.verticalHeader().setVisible(False)
|
||||
self.segments_table.setAlternatingRowColors(True)
|
||||
self.segments_table.setSelectionBehavior(QTableWidget.SelectRows)
|
||||
layout.addWidget(self.segments_table, 1)
|
||||
|
||||
self.add_segment_button.clicked.connect(self._add_segment_row)
|
||||
self.remove_segment_button.clicked.connect(self._remove_selected_segments)
|
||||
|
||||
def _build_raw_tab(self) -> None:
|
||||
layout = QVBoxLayout(self.raw_tab)
|
||||
|
||||
button_row = QHBoxLayout()
|
||||
self.refresh_raw_button = QPushButton("Refresh From Tables")
|
||||
self.validate_raw_button = QPushButton("Validate XML")
|
||||
self.apply_raw_button = QPushButton("Apply XML To Tables")
|
||||
button_row.addWidget(self.refresh_raw_button)
|
||||
button_row.addWidget(self.validate_raw_button)
|
||||
button_row.addWidget(self.apply_raw_button)
|
||||
button_row.addStretch(1)
|
||||
layout.addLayout(button_row)
|
||||
|
||||
self.raw_editor = QPlainTextEdit()
|
||||
self.raw_editor.setLineWrapMode(QPlainTextEdit.NoWrap)
|
||||
self.raw_editor.document().setModified(False)
|
||||
layout.addWidget(self.raw_editor, 1)
|
||||
|
||||
layout.addWidget(QLabel("Validation"))
|
||||
self.error_list = QListWidget()
|
||||
layout.addWidget(self.error_list)
|
||||
|
||||
self.refresh_raw_button.clicked.connect(self._refresh_raw_xml)
|
||||
self.validate_raw_button.clicked.connect(self._validate_raw_xml)
|
||||
self.apply_raw_button.clicked.connect(self._apply_raw_xml)
|
||||
|
||||
def _populate_tiles_table(self) -> None:
|
||||
self.tiles_table.setRowCount(len(self.working_config.tiles))
|
||||
for row, tile in enumerate(self.working_config.sorted_tiles()):
|
||||
values = {
|
||||
"tile_id": tile.tile_id,
|
||||
"row": str(tile.row),
|
||||
"col": str(tile.col),
|
||||
"screen_name": tile.screen_name,
|
||||
"controller_ip": tile.controller_ip,
|
||||
"controller_name": tile.controller_name,
|
||||
"controller_host": tile.controller_host,
|
||||
"controller_mac": tile.controller_mac,
|
||||
"subnet": str(tile.subnet),
|
||||
"universe": str(tile.universe),
|
||||
"led_total": str(tile.led_total),
|
||||
"brightness_factor": f"{tile.brightness_factor:.3f}",
|
||||
"enabled": tile.enabled,
|
||||
}
|
||||
for column, (key, _) in enumerate(self.TILE_COLUMNS):
|
||||
if key == "enabled":
|
||||
item = QTableWidgetItem()
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
item.setCheckState(Qt.Checked if tile.enabled else Qt.Unchecked)
|
||||
else:
|
||||
item = QTableWidgetItem(str(values[key]))
|
||||
self.tiles_table.setItem(row, column, item)
|
||||
self.tiles_table.resizeColumnsToContents()
|
||||
|
||||
def _rebuild_segment_tile_combo(self) -> None:
|
||||
self.segment_tile_combo.blockSignals(True)
|
||||
self.segment_tile_combo.clear()
|
||||
for tile in self.working_config.sorted_tiles():
|
||||
self.segment_tile_combo.addItem(tile.tile_id, tile.tile_id)
|
||||
current_tile_id = self._active_segment_tile_id or (self.working_config.sorted_tiles()[0].tile_id if self.working_config.tiles else None)
|
||||
if current_tile_id:
|
||||
index = self.segment_tile_combo.findData(current_tile_id)
|
||||
self.segment_tile_combo.setCurrentIndex(max(0, index))
|
||||
self._active_segment_tile_id = self.segment_tile_combo.currentData()
|
||||
self.segment_tile_combo.blockSignals(False)
|
||||
self._populate_segments_table()
|
||||
|
||||
def _populate_segments_table(self) -> None:
|
||||
tile = self._active_tile_for_segments()
|
||||
self.segments_table.setRowCount(0)
|
||||
if tile is None:
|
||||
return
|
||||
self.segments_table.setRowCount(len(tile.segments))
|
||||
for row, segment in enumerate(tile.segments):
|
||||
values = {
|
||||
"name": segment.name,
|
||||
"side": segment.side,
|
||||
"start_channel": str(segment.start_channel),
|
||||
"led_count": str(segment.led_count),
|
||||
"orientation_rad": f"{segment.orientation_rad:.5f}",
|
||||
"x0": f"{segment.x0:.3f}",
|
||||
"y0": f"{segment.y0:.3f}",
|
||||
"x1": f"{segment.x1:.3f}",
|
||||
"y1": f"{segment.y1:.3f}",
|
||||
"reverse": segment.reverse,
|
||||
}
|
||||
for column, (key, _) in enumerate(self.SEGMENT_COLUMNS):
|
||||
if key == "reverse":
|
||||
item = QTableWidgetItem()
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
item.setCheckState(Qt.Checked if segment.reverse else Qt.Unchecked)
|
||||
else:
|
||||
item = QTableWidgetItem(str(values[key]))
|
||||
self.segments_table.setItem(row, column, item)
|
||||
self.segments_table.resizeColumnsToContents()
|
||||
|
||||
def _active_tile_for_segments(self):
|
||||
return self.working_config.tile_lookup().get(self.segment_tile_combo.currentData())
|
||||
|
||||
def _on_segment_tile_changed(self) -> None:
|
||||
previous_tile_id = self._active_segment_tile_id
|
||||
self._sync_segments_table(previous_tile_id)
|
||||
self._active_segment_tile_id = self.segment_tile_combo.currentData()
|
||||
self._populate_segments_table()
|
||||
|
||||
def _add_segment_row(self) -> None:
|
||||
row = self.segments_table.rowCount()
|
||||
self.segments_table.insertRow(row)
|
||||
defaults = ["New Segment", "left", "1", "1", "0.0", "0.0", "0.0", "0.0", "0.0", False]
|
||||
for column, (_, _) in enumerate(self.SEGMENT_COLUMNS):
|
||||
value = defaults[column]
|
||||
if column == len(self.SEGMENT_COLUMNS) - 1:
|
||||
item = QTableWidgetItem()
|
||||
item.setFlags(item.flags() | Qt.ItemIsUserCheckable | Qt.ItemIsEnabled | Qt.ItemIsSelectable)
|
||||
item.setCheckState(Qt.Unchecked)
|
||||
else:
|
||||
item = QTableWidgetItem(str(value))
|
||||
self.segments_table.setItem(row, column, item)
|
||||
|
||||
def _remove_selected_segments(self) -> None:
|
||||
rows = sorted({item.row() for item in self.segments_table.selectedItems()}, reverse=True)
|
||||
for row in rows:
|
||||
self.segments_table.removeRow(row)
|
||||
|
||||
def _refresh_raw_xml(self) -> None:
|
||||
self._sync_tables_to_config()
|
||||
self._set_raw_xml_snapshot()
|
||||
self.error_list.clear()
|
||||
|
||||
def _set_raw_xml_snapshot(self) -> None:
|
||||
self.raw_editor.setPlainText(config_to_xml_string(self.working_config))
|
||||
self.raw_editor.document().setModified(False)
|
||||
|
||||
def _validate_raw_xml(self) -> None:
|
||||
self.error_list.clear()
|
||||
try:
|
||||
config = load_config_from_string(self.raw_editor.toPlainText(), validate=True)
|
||||
self.error_list.addItem(f"XML valid. {len(config.tiles)} tiles loaded.")
|
||||
except MappingValidationError as exc:
|
||||
for error in exc.errors:
|
||||
self.error_list.addItem(error)
|
||||
|
||||
def _apply_raw_xml(self) -> None:
|
||||
self.error_list.clear()
|
||||
try:
|
||||
self.working_config = load_config_from_string(self.raw_editor.toPlainText(), validate=True)
|
||||
self.name_edit.setText(self.working_config.name)
|
||||
self.rows_spin.setValue(self.working_config.logical_display.rows)
|
||||
self.cols_spin.setValue(self.working_config.logical_display.cols)
|
||||
self._populate_tiles_table()
|
||||
self._rebuild_segment_tile_combo()
|
||||
self._refresh_network_mapping_ui()
|
||||
self.raw_editor.document().setModified(False)
|
||||
self.error_list.addItem("Applied XML to editable tables.")
|
||||
except MappingValidationError as exc:
|
||||
for error in exc.errors:
|
||||
self.error_list.addItem(error)
|
||||
|
||||
def _handle_tab_changed(self, index: int) -> None:
|
||||
try:
|
||||
if self.tabs.widget(index) is self.network_tab:
|
||||
self._sync_general_fields()
|
||||
self._sync_tiles_table()
|
||||
self._sync_segments_table()
|
||||
self._refresh_network_mapping_ui()
|
||||
elif self.tabs.widget(index) is self.segments_tab:
|
||||
self._sync_tiles_table()
|
||||
self._rebuild_segment_tile_combo()
|
||||
elif self.tabs.widget(index) is self.raw_tab and not self.raw_editor.document().isModified():
|
||||
self._refresh_raw_xml()
|
||||
except (MappingValidationError, ValueError, KeyError, StopIteration) as exc:
|
||||
self.tabs.blockSignals(True)
|
||||
self.tabs.setCurrentWidget(self.tiles_tab)
|
||||
self.tabs.blockSignals(False)
|
||||
QMessageBox.warning(
|
||||
self,
|
||||
"Mapping Error",
|
||||
f"Please resolve the current tile or segment values before switching tabs.\n\n{exc}",
|
||||
)
|
||||
|
||||
def _sync_tables_to_config(self) -> None:
|
||||
self._sync_general_fields()
|
||||
self._sync_tiles_table()
|
||||
self._sync_segments_table()
|
||||
|
||||
def _sync_general_fields(self) -> None:
|
||||
self.working_config.name = self.name_edit.text().strip() or self.working_config.name
|
||||
self.working_config.logical_display.rows = self.rows_spin.value()
|
||||
self.working_config.logical_display.cols = self.cols_spin.value()
|
||||
|
||||
def _sync_tiles_table(self) -> None:
|
||||
tiles = self.working_config.sorted_tiles()
|
||||
for row, tile in enumerate(tiles):
|
||||
tile.tile_id = self._text(row, "tile_id")
|
||||
tile.row = int(self._text(row, "row"))
|
||||
tile.col = int(self._text(row, "col"))
|
||||
tile.screen_name = self._text(row, "screen_name")
|
||||
tile.controller_ip = self._text(row, "controller_ip")
|
||||
tile.controller_name = self._text(row, "controller_name")
|
||||
tile.controller_host = self._text(row, "controller_host")
|
||||
tile.controller_mac = self._text(row, "controller_mac")
|
||||
tile.subnet = int(self._text(row, "subnet"))
|
||||
tile.universe = int(self._text(row, "universe"))
|
||||
tile.led_total = int(self._text(row, "led_total"))
|
||||
tile.brightness_factor = float(self._text(row, "brightness_factor"))
|
||||
tile.enabled = self._check(row, "enabled")
|
||||
self.working_config.tiles = tiles
|
||||
|
||||
def _sync_segments_table(self, tile_id: str | None = None) -> None:
|
||||
lookup_id = tile_id if tile_id is not None else self._active_segment_tile_id or self.segment_tile_combo.currentData()
|
||||
tile = self.working_config.tile_lookup().get(lookup_id)
|
||||
if tile is None:
|
||||
return
|
||||
segments: list[SegmentConfig] = []
|
||||
for row in range(self.segments_table.rowCount()):
|
||||
segments.append(
|
||||
SegmentConfig(
|
||||
name=self._segment_text(row, "name"),
|
||||
side=self._segment_text(row, "side"),
|
||||
start_channel=int(self._segment_text(row, "start_channel")),
|
||||
led_count=int(self._segment_text(row, "led_count")),
|
||||
orientation_rad=float(self._segment_text(row, "orientation_rad")),
|
||||
x0=float(self._segment_text(row, "x0")),
|
||||
y0=float(self._segment_text(row, "y0")),
|
||||
x1=float(self._segment_text(row, "x1")),
|
||||
y1=float(self._segment_text(row, "y1")),
|
||||
reverse=self._segment_check(row, "reverse"),
|
||||
)
|
||||
)
|
||||
tile.segments = segments
|
||||
|
||||
def _scan_network(self) -> None:
|
||||
self._sync_general_fields()
|
||||
self._sync_tiles_table()
|
||||
self._sync_segments_table()
|
||||
|
||||
self.discovered_devices = []
|
||||
self._active_device_key = None
|
||||
self._selected_assignment_tile_id = None
|
||||
self._scan_worker = NetworkScanWorker(self.working_config)
|
||||
self._scan_worker.progress.connect(self._on_scan_progress)
|
||||
self._scan_worker.finished.connect(self._on_scan_finished)
|
||||
|
||||
self.scan_button.setEnabled(False)
|
||||
self.scan_status_label.setText("Scanning local subnets for WLED devices...")
|
||||
self.assignment_feedback_label.setText("Scanning the network. Results will appear below as devices respond.")
|
||||
self._refresh_network_mapping_ui()
|
||||
self._scan_worker.start()
|
||||
|
||||
def _on_scan_progress(self, completed: int, total: int, device: DiscoveredWledDevice | None) -> None:
|
||||
self.scan_status_label.setText(f"Scanning {completed}/{total} hosts...")
|
||||
if device is None:
|
||||
return
|
||||
if any(self._device_key(existing) == self._device_key(device) for existing in self.discovered_devices):
|
||||
return
|
||||
self.discovered_devices.append(device)
|
||||
self.discovered_devices.sort(key=lambda item: tuple(int(part) for part in item.ip_address.split(".")))
|
||||
if self._active_device() is None:
|
||||
self._active_device_key = self._device_key(self.discovered_devices[0])
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
def _on_scan_finished(self, devices: list[DiscoveredWledDevice], error_message: str, host_count: int) -> None:
|
||||
self.scan_button.setEnabled(True)
|
||||
self._scan_worker = None
|
||||
self._last_scan_host_count = host_count
|
||||
|
||||
if error_message:
|
||||
self.scan_status_label.setText("Network scan failed.")
|
||||
self.assignment_feedback_label.setText(error_message)
|
||||
QMessageBox.warning(self, "Network Scan Error", error_message)
|
||||
return
|
||||
|
||||
self.discovered_devices = list(devices)
|
||||
if self._active_device() is None and self.discovered_devices:
|
||||
self._active_device_key = self._device_key(self.discovered_devices[0])
|
||||
if not self.discovered_devices:
|
||||
self.scan_status_label.setText(f"No WLED devices found after scanning {host_count} hosts.")
|
||||
self.assignment_feedback_label.setText("No WLED devices were detected. Check the subnet, power, and network connectivity.")
|
||||
else:
|
||||
self.scan_status_label.setText(f"Found {len(self.discovered_devices)} WLED device(s) across {host_count} hosts.")
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
def _on_discovered_device_selection_changed(self) -> None:
|
||||
if self._device_table_refreshing:
|
||||
return
|
||||
row = self.discovered_devices_table.currentRow()
|
||||
if row < 0:
|
||||
return
|
||||
item = self.discovered_devices_table.item(row, 0)
|
||||
if item is None:
|
||||
return
|
||||
device_key = item.data(Qt.UserRole)
|
||||
if isinstance(device_key, str):
|
||||
self._select_device_for_assignment(device_key, select_table=False)
|
||||
|
||||
def _select_device_for_assignment(self, device_key: str, *, select_table: bool = True) -> None:
|
||||
self._active_device_key = device_key
|
||||
active_device = self._active_device()
|
||||
mapped_tile = find_tile_for_device(self.working_config, active_device) if active_device is not None else None
|
||||
self._selected_assignment_tile_id = mapped_tile.tile_id if mapped_tile is not None else self._selected_assignment_tile_id
|
||||
if select_table:
|
||||
self._select_device_row(device_key)
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
def _select_device_row(self, device_key: str) -> None:
|
||||
for row in range(self.discovered_devices_table.rowCount()):
|
||||
item = self.discovered_devices_table.item(row, 0)
|
||||
if item is None:
|
||||
continue
|
||||
if item.data(Qt.UserRole) == device_key:
|
||||
self.discovered_devices_table.selectRow(row)
|
||||
return
|
||||
|
||||
def _identify_device(self, device_key: str) -> None:
|
||||
device = self._device_for_key(device_key)
|
||||
if device is None:
|
||||
return
|
||||
|
||||
self._select_device_for_assignment(device_key)
|
||||
worker = DeviceIdentifyWorker(device)
|
||||
self._identify_workers.add(worker)
|
||||
worker.finished.connect(self._on_identify_finished)
|
||||
self.assignment_feedback_label.setText(
|
||||
f"Identifying {device.ip_address}. Watch for the red pulse, then click the matching tile."
|
||||
)
|
||||
worker.start()
|
||||
|
||||
def _on_identify_finished(self, device: DiscoveredWledDevice, error_message: str) -> None:
|
||||
for worker in list(self._identify_workers):
|
||||
if worker._device == device:
|
||||
self._identify_workers.discard(worker)
|
||||
break
|
||||
if error_message:
|
||||
self.assignment_feedback_label.setText(error_message)
|
||||
QMessageBox.warning(self, "Identify Failed", error_message)
|
||||
else:
|
||||
mapped_tile = find_tile_for_device(self.working_config, device)
|
||||
if mapped_tile is not None:
|
||||
self._selected_assignment_tile_id = mapped_tile.tile_id
|
||||
self.assignment_feedback_label.setText(
|
||||
f"{device.ip_address} pulsed successfully. Click the matching tile to store the assignment."
|
||||
)
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
def _handle_mapping_preview_click(self, tile_id: str) -> None:
|
||||
self._selected_assignment_tile_id = tile_id
|
||||
active_device = self._active_device()
|
||||
if active_device is None:
|
||||
self.assignment_feedback_label.setText(f"{tile_id} selected. Choose a device row or press Identify first.")
|
||||
self._refresh_network_mapping_ui()
|
||||
return
|
||||
self._assign_active_device_to_tile(active_device, tile_id)
|
||||
|
||||
def _assign_active_device_to_tile(self, device: DiscoveredWledDevice, tile_id: str) -> None:
|
||||
tile_lookup = self.working_config.tile_lookup()
|
||||
target_tile = tile_lookup.get(tile_id)
|
||||
if target_tile is None:
|
||||
self.assignment_feedback_label.setText(f"Tile {tile_id} no longer exists in the current mapping.")
|
||||
return
|
||||
|
||||
previous_tile = find_tile_for_device(self.working_config, device)
|
||||
displaced_tile_text = ""
|
||||
if target_tile.controller_ip or target_tile.controller_mac:
|
||||
if not tile_matches_device(target_tile, device):
|
||||
displaced_tile_text = target_tile.controller_ip or target_tile.controller_name or target_tile.controller_mac
|
||||
|
||||
assign_device_to_tile(self.working_config, device, tile_id)
|
||||
self._selected_assignment_tile_id = tile_id
|
||||
self._populate_tiles_table()
|
||||
if not self.raw_editor.document().isModified():
|
||||
self._set_raw_xml_snapshot()
|
||||
|
||||
persisted, persistence_message = self._persist_working_mapping()
|
||||
|
||||
summary_parts = [f"{device.ip_address} -> {tile_id}"]
|
||||
if previous_tile is not None and previous_tile.tile_id != tile_id:
|
||||
summary_parts.append(f"moved from {previous_tile.tile_id}")
|
||||
if displaced_tile_text:
|
||||
summary_parts.append(f"replaced {displaced_tile_text}")
|
||||
summary = ", ".join(summary_parts)
|
||||
|
||||
if persisted:
|
||||
self.assignment_feedback_label.setText(f"Saved {summary}. {persistence_message}")
|
||||
else:
|
||||
self.assignment_feedback_label.setText(f"Stored {summary} in the dialog. {persistence_message}")
|
||||
QMessageBox.warning(self, "Assignment Not Saved Yet", persistence_message)
|
||||
|
||||
self._refresh_network_mapping_ui()
|
||||
self._select_next_unmapped_device()
|
||||
|
||||
def _persist_working_mapping(self) -> tuple[bool, str]:
|
||||
if self.raw_editor.document().isModified():
|
||||
return (
|
||||
False,
|
||||
"Raw XML has unapplied changes. Apply or discard those edits before assisted assignments can be written to disk.",
|
||||
)
|
||||
|
||||
target_path = None
|
||||
if self.controller is not None and self.controller.mapping_path is not None:
|
||||
target_path = self.controller.mapping_path
|
||||
elif self.working_config.file_path is not None:
|
||||
target_path = self.working_config.file_path
|
||||
|
||||
if target_path is None:
|
||||
return False, "No mapping file is currently open. Use Save or Save As after closing Mapping Settings."
|
||||
|
||||
validation = validate_config(self.working_config)
|
||||
if not validation.is_valid:
|
||||
return False, "The mapping is currently invalid and could not be saved: " + "; ".join(validation.errors)
|
||||
|
||||
try:
|
||||
save_config(self.working_config, target_path)
|
||||
except (MappingValidationError, OSError, ValueError) as exc:
|
||||
if isinstance(exc, MappingValidationError):
|
||||
return False, "The mapping could not be saved: " + "; ".join(exc.errors)
|
||||
return False, f"The mapping file could not be written: {exc}"
|
||||
|
||||
if self.controller is not None:
|
||||
self.controller.replace_config(self.working_config.clone(), path=target_path)
|
||||
return True, f"Wrote {target_path.name}."
|
||||
|
||||
def _select_next_unmapped_device(self) -> None:
|
||||
for device in self.discovered_devices:
|
||||
if find_tile_for_device(self.working_config, device) is None:
|
||||
self._select_device_for_assignment(self._device_key(device))
|
||||
return
|
||||
self._refresh_network_mapping_ui()
|
||||
|
||||
def _refresh_network_mapping_ui(self) -> None:
|
||||
active_device = self._active_device()
|
||||
mapped_count = sum(1 for device in self.discovered_devices if find_tile_for_device(self.working_config, device) is not None)
|
||||
stale_tile_count = sum(
|
||||
1
|
||||
for tile in self.working_config.sorted_tiles()
|
||||
if (tile.controller_ip.strip() or tile.controller_mac.strip())
|
||||
and not any(tile_matches_device(tile, device) for device in self.discovered_devices)
|
||||
)
|
||||
unmapped_count = max(0, len(self.discovered_devices) - mapped_count)
|
||||
|
||||
if not self.discovered_devices:
|
||||
summary = "No devices discovered yet."
|
||||
else:
|
||||
summary = f"{len(self.discovered_devices)} discovered | {mapped_count} mapped | {unmapped_count} unmapped"
|
||||
if stale_tile_count:
|
||||
summary += f" | {stale_tile_count} saved assignment(s) not seen in this scan"
|
||||
self.mapping_summary_label.setText(summary)
|
||||
|
||||
if active_device is None:
|
||||
self.assignment_workflow_label.setText("Select a device row or press Identify, then click the matching tile.")
|
||||
else:
|
||||
mapped_tile = find_tile_for_device(self.working_config, active_device)
|
||||
if mapped_tile is None:
|
||||
self.assignment_workflow_label.setText(
|
||||
f"Active device: {active_device.ip_address}. Click the physical tile that just flashed."
|
||||
)
|
||||
else:
|
||||
self.assignment_workflow_label.setText(
|
||||
f"Active device: {active_device.ip_address} is currently mapped to {mapped_tile.tile_id}. "
|
||||
"Click a different tile to reassign it."
|
||||
)
|
||||
|
||||
self.mapping_preview.set_assignment_state(
|
||||
self.working_config,
|
||||
self.discovered_devices,
|
||||
active_device=active_device,
|
||||
selected_tile_id=self._selected_assignment_tile_id,
|
||||
)
|
||||
self._refresh_discovered_devices_table()
|
||||
|
||||
def _refresh_discovered_devices_table(self) -> None:
|
||||
self._device_table_refreshing = True
|
||||
try:
|
||||
self.discovered_devices_table.setRowCount(len(self.discovered_devices))
|
||||
for row, device in enumerate(self.discovered_devices):
|
||||
mapped_tile = find_tile_for_device(self.working_config, device)
|
||||
status = "Unmapped"
|
||||
if mapped_tile is not None:
|
||||
if mapped_tile.controller_ip.strip() and mapped_tile.controller_ip.strip() != device.ip_address:
|
||||
status = f"Mapped to {mapped_tile.tile_id} (saved IP {mapped_tile.controller_ip})"
|
||||
else:
|
||||
status = f"Mapped to {mapped_tile.tile_id}"
|
||||
|
||||
values = [
|
||||
device.ip_address,
|
||||
device.hostname,
|
||||
device.instance_name,
|
||||
device.mac_address,
|
||||
status,
|
||||
]
|
||||
device_key = self._device_key(device)
|
||||
for column, value in enumerate(values):
|
||||
item = QTableWidgetItem(value)
|
||||
item.setData(Qt.UserRole, device_key)
|
||||
if device_key == self._active_device_key:
|
||||
item.setBackground(Qt.darkBlue)
|
||||
item.setForeground(Qt.white)
|
||||
elif mapped_tile is not None:
|
||||
item.setBackground(Qt.darkGreen)
|
||||
self.discovered_devices_table.setItem(row, column, item)
|
||||
|
||||
map_button = QPushButton("Map This")
|
||||
map_button.clicked.connect(lambda _checked=False, key=device_key: self._select_device_for_assignment(key))
|
||||
self.discovered_devices_table.setCellWidget(row, 5, map_button)
|
||||
|
||||
identify_button = QPushButton("Identify")
|
||||
identify_button.clicked.connect(lambda _checked=False, key=device_key: self._identify_device(key))
|
||||
self.discovered_devices_table.setCellWidget(row, 6, identify_button)
|
||||
|
||||
self.discovered_devices_table.resizeRowsToContents()
|
||||
if self._active_device_key is not None:
|
||||
self._select_device_row(self._active_device_key)
|
||||
finally:
|
||||
self._device_table_refreshing = False
|
||||
|
||||
def _device_for_key(self, device_key: str | None) -> DiscoveredWledDevice | None:
|
||||
if not device_key:
|
||||
return None
|
||||
for device in self.discovered_devices:
|
||||
if self._device_key(device) == device_key:
|
||||
return device
|
||||
return None
|
||||
|
||||
def _active_device(self) -> DiscoveredWledDevice | None:
|
||||
return self._device_for_key(self._active_device_key)
|
||||
|
||||
def _device_key(self, device: DiscoveredWledDevice) -> str:
|
||||
return device.mac_address or device.ip_address
|
||||
|
||||
def _text(self, row: int, key: str) -> str:
|
||||
column = next(index for index, (field, _) in enumerate(self.TILE_COLUMNS) if field == key)
|
||||
item = self.tiles_table.item(row, column)
|
||||
return item.text().strip() if item else ""
|
||||
|
||||
def _segment_text(self, row: int, key: str) -> str:
|
||||
column = next(index for index, (field, _) in enumerate(self.SEGMENT_COLUMNS) if field == key)
|
||||
item = self.segments_table.item(row, column)
|
||||
return item.text().strip() if item else ""
|
||||
|
||||
def _check(self, row: int, key: str) -> bool:
|
||||
column = next(index for index, (field, _) in enumerate(self.TILE_COLUMNS) if field == key)
|
||||
item = self.tiles_table.item(row, column)
|
||||
return bool(item and item.checkState() == Qt.Checked)
|
||||
|
||||
def _segment_check(self, row: int, key: str) -> bool:
|
||||
column = next(index for index, (field, _) in enumerate(self.SEGMENT_COLUMNS) if field == key)
|
||||
item = self.segments_table.item(row, column)
|
||||
return bool(item and item.checkState() == Qt.Checked)
|
||||
|
||||
def _save_and_accept(self) -> None:
|
||||
try:
|
||||
if self.raw_editor.document().isModified():
|
||||
self.working_config = load_config_from_string(self.raw_editor.toPlainText(), validate=True)
|
||||
else:
|
||||
self._sync_tables_to_config()
|
||||
result = validate_config(self.working_config)
|
||||
if not result.is_valid:
|
||||
raise MappingValidationError(result.errors)
|
||||
except (MappingValidationError, ValueError) as exc:
|
||||
errors = exc.errors if isinstance(exc, MappingValidationError) else [str(exc)]
|
||||
self.error_list.clear()
|
||||
for error in errors:
|
||||
self.error_list.addItem(error)
|
||||
self.tabs.setCurrentWidget(self.raw_tab)
|
||||
QMessageBox.warning(self, "Validation Error", "Please resolve the validation errors before saving.")
|
||||
return
|
||||
|
||||
self.result_config = self.working_config
|
||||
self.accept()
|
||||
+189
@@ -0,0 +1,189 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from app.qt_compat import QApplication, QColor, QFont, QPalette
|
||||
|
||||
|
||||
def apply_dark_theme(app: QApplication) -> None:
|
||||
app.setStyle("Fusion")
|
||||
font = QFont(app.font())
|
||||
if font.pointSizeF() < 11.5:
|
||||
font.setPointSizeF(11.5)
|
||||
if hasattr(font, "setFamilies"):
|
||||
font.setFamilies(["Segoe UI Variable Text", "Segoe UI", "Bahnschrift"])
|
||||
else:
|
||||
font.setFamily("Segoe UI")
|
||||
app.setFont(font)
|
||||
|
||||
palette = QPalette()
|
||||
palette.setColor(QPalette.Window, QColor("#1E1E1E"))
|
||||
palette.setColor(QPalette.WindowText, QColor("#CCCCCC"))
|
||||
palette.setColor(QPalette.Base, QColor("#252526"))
|
||||
palette.setColor(QPalette.AlternateBase, QColor("#2D2D30"))
|
||||
palette.setColor(QPalette.ToolTipBase, QColor("#252526"))
|
||||
palette.setColor(QPalette.ToolTipText, QColor("#CCCCCC"))
|
||||
palette.setColor(QPalette.Text, QColor("#CCCCCC"))
|
||||
palette.setColor(QPalette.Button, QColor("#2D2D30"))
|
||||
palette.setColor(QPalette.ButtonText, QColor("#CCCCCC"))
|
||||
palette.setColor(QPalette.Highlight, QColor("#007ACC"))
|
||||
palette.setColor(QPalette.HighlightedText, QColor("#FFFFFF"))
|
||||
palette.setColor(QPalette.BrightText, QColor("#FFFFFF"))
|
||||
palette.setColor(QPalette.PlaceholderText, QColor("#7A7A7A"))
|
||||
app.setPalette(palette)
|
||||
|
||||
app.setStyleSheet(
|
||||
"""
|
||||
QWidget {
|
||||
background: #1E1E1E;
|
||||
color: #CCCCCC;
|
||||
}
|
||||
QMainWindow, QDialog {
|
||||
background: #1E1E1E;
|
||||
}
|
||||
QToolBar {
|
||||
background: #2D2D30;
|
||||
border: none;
|
||||
border-bottom: 1px solid #3C3C3C;
|
||||
spacing: 4px;
|
||||
padding: 4px 6px;
|
||||
}
|
||||
QToolButton {
|
||||
background: transparent;
|
||||
color: #CCCCCC;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 3px;
|
||||
padding: 6px 10px;
|
||||
margin: 0 1px;
|
||||
}
|
||||
QToolButton:hover {
|
||||
background: #37373D;
|
||||
border-color: #37373D;
|
||||
}
|
||||
QToolButton:pressed, QToolButton:checked {
|
||||
background: #094771;
|
||||
border-color: #007ACC;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
QStatusBar {
|
||||
background: #007ACC;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
QStatusBar QLabel {
|
||||
background: transparent;
|
||||
color: #FFFFFF;
|
||||
padding: 0 4px;
|
||||
}
|
||||
QWidget#sectionHeader {
|
||||
background: transparent;
|
||||
border: none;
|
||||
}
|
||||
QLabel#sectionHeaderLabel {
|
||||
background: transparent;
|
||||
color: #CCCCCC;
|
||||
font-size: 26px;
|
||||
font-weight: 600;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
QWidget#sectionBody {
|
||||
background: #252526;
|
||||
border: 1px solid #3C3C3C;
|
||||
border-radius: 0;
|
||||
}
|
||||
QGroupBox {
|
||||
background: #252526;
|
||||
border: 1px solid #3C3C3C;
|
||||
border-radius: 0;
|
||||
margin-top: 30px;
|
||||
font-weight: 600;
|
||||
padding: 10px 12px 12px 12px;
|
||||
}
|
||||
QGroupBox::title {
|
||||
subcontrol-origin: margin;
|
||||
subcontrol-position: top left;
|
||||
top: 7px;
|
||||
left: 12px;
|
||||
background: transparent;
|
||||
padding: 0 0 0 0;
|
||||
color: #CCCCCC;
|
||||
font-size: 12px;
|
||||
}
|
||||
QLabel {
|
||||
background: transparent;
|
||||
border: none;
|
||||
padding: 0;
|
||||
}
|
||||
QPushButton, QComboBox, QLineEdit, QSpinBox, QDoubleSpinBox, QPlainTextEdit, QListWidget, QTableWidget {
|
||||
background: #1F1F1F;
|
||||
border: 1px solid #3C3C3C;
|
||||
border-radius: 3px;
|
||||
padding: 7px 10px;
|
||||
selection-background-color: #094771;
|
||||
selection-color: #FFFFFF;
|
||||
}
|
||||
QPushButton:hover, QComboBox:hover, QLineEdit:hover, QSpinBox:hover, QDoubleSpinBox:hover {
|
||||
border-color: #007ACC;
|
||||
}
|
||||
QPushButton {
|
||||
background: #2D2D30;
|
||||
min-height: 32px;
|
||||
}
|
||||
QPushButton:checked {
|
||||
background: #094771;
|
||||
color: #FFFFFF;
|
||||
border-color: #007ACC;
|
||||
}
|
||||
QComboBox::drop-down {
|
||||
border: none;
|
||||
width: 24px;
|
||||
}
|
||||
QListWidget::item {
|
||||
padding: 6px 8px;
|
||||
border-radius: 2px;
|
||||
margin: 1px 0;
|
||||
}
|
||||
QListWidget::item:selected {
|
||||
background: #094771;
|
||||
border: 1px solid #007ACC;
|
||||
}
|
||||
QHeaderView::section {
|
||||
background: #2D2D30;
|
||||
color: #CCCCCC;
|
||||
border: none;
|
||||
border-right: 1px solid #3C3C3C;
|
||||
padding: 6px 8px;
|
||||
}
|
||||
QTabWidget::pane {
|
||||
border: 1px solid #3C3C3C;
|
||||
border-radius: 0;
|
||||
top: -1px;
|
||||
}
|
||||
QTabBar::tab {
|
||||
background: #2D2D30;
|
||||
border: 1px solid #3C3C3C;
|
||||
border-bottom: none;
|
||||
padding: 8px 14px;
|
||||
margin-right: 2px;
|
||||
border-top-left-radius: 3px;
|
||||
border-top-right-radius: 3px;
|
||||
}
|
||||
QTabBar::tab:selected {
|
||||
background: #1E1E1E;
|
||||
color: #FFFFFF;
|
||||
}
|
||||
QScrollBar:vertical {
|
||||
background: #252526;
|
||||
width: 12px;
|
||||
}
|
||||
QScrollBar::handle:vertical {
|
||||
background: #424242;
|
||||
border-radius: 6px;
|
||||
min-height: 30px;
|
||||
}
|
||||
QScrollBar::handle:vertical:hover {
|
||||
background: #4F4F4F;
|
||||
}
|
||||
QSplitter::handle {
|
||||
background: #2D2D30;
|
||||
}
|
||||
"""
|
||||
)
|
||||
Reference in New Issue
Block a user