Add EntitySelect/IconSelect UI improvements across modals

- Portal IconSelect popups to document.body with position:fixed to prevent
  clipping by modal overflow-y:auto
- Replace custom scene selectors in automation editor with EntitySelect
  command-palette pickers (main scene + fallback scene)
- Add IconSelect grid for automation deactivation mode (none/revert/fallback)
- Add IconSelect grid for automation condition type and match type
- Replace mapped zone source dropdowns with EntitySelect pickers
- Replace scene target selector with EntityPalette.pick() pattern
- Remove effect palette preview bar from CSS editor
- Remove sensitivity badge from audio color strip source cards
- Clean up unused scene-selector CSS and scene-target-add-row CSS
- Add locale keys for all new UI elements across en/ru/zh

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-09 16:00:30 +03:00
parent 186940124c
commit 2712c6682e
32 changed files with 1204 additions and 391 deletions

View File

@@ -19,7 +19,7 @@ from typing import Optional
import numpy as np
from wled_controller.core.capture.calibration import CalibrationConfig, PixelMapper
from wled_controller.core.capture.calibration import CalibrationConfig, PixelMapper, AdvancedPixelMapper, create_pixel_mapper
from wled_controller.core.capture.screen_capture import extract_border_pixels
from wled_controller.core.processing.live_stream import LiveStream
from wled_controller.utils import get_logger
@@ -151,15 +151,22 @@ class PictureColorStripStream(ColorStripStream):
restarting the thread (except when the underlying LiveStream changes).
"""
def __init__(self, live_stream: LiveStream, source):
def __init__(self, live_stream, source):
"""
Args:
live_stream: Acquired LiveStream (lifecycle managed by ColorStripStreamManager)
live_stream: Acquired LiveStream or Dict[str, LiveStream] for advanced mode.
Lifecycle managed by ColorStripStreamManager.
source: PictureColorStripSource config
"""
from wled_controller.storage.color_strip_source import PictureColorStripSource
self._live_stream = live_stream
# Support both single LiveStream and dict of streams (advanced mode)
if isinstance(live_stream, dict):
self._live_streams = live_stream
self._live_stream = next(iter(live_stream.values()))
else:
self._live_streams = {}
self._live_stream = live_stream
self._fps: int = 30 # internal capture rate (send FPS is on the target)
self._smoothing: float = source.smoothing
self._brightness: float = source.brightness
@@ -167,7 +174,7 @@ class PictureColorStripStream(ColorStripStream):
self._gamma: float = source.gamma
self._interpolation_mode: str = source.interpolation_mode
self._calibration: CalibrationConfig = source.calibration
self._pixel_mapper = PixelMapper(
self._pixel_mapper = create_pixel_mapper(
self._calibration, interpolation_mode=self._interpolation_mode
)
cal_leds = self._calibration.get_total_leds()
@@ -201,6 +208,8 @@ class PictureColorStripStream(ColorStripStream):
@property
def display_index(self) -> Optional[int]:
if self._live_streams:
return None # multi-source, ambiguous
return self._live_stream.display_index
@property
@@ -255,9 +264,9 @@ class PictureColorStripStream(ColorStripStream):
PixelMapper is rebuilt atomically if calibration or interpolation_mode changed.
"""
from wled_controller.storage.color_strip_source import PictureColorStripSource
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource
if not isinstance(source, PictureColorStripSource):
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
return
self._smoothing = source.smoothing
@@ -277,7 +286,7 @@ class PictureColorStripStream(ColorStripStream):
self._calibration = source.calibration
cal_leds = source.calibration.get_total_leds()
self._led_count = source.led_count if source.led_count > 0 else cal_leds
self._pixel_mapper = PixelMapper(
self._pixel_mapper = create_pixel_mapper(
source.calibration, interpolation_mode=source.interpolation_mode
)
self._previous_colors = None # Reset smoothing history on calibration change
@@ -375,10 +384,21 @@ class PictureColorStripStream(ColorStripStream):
t0 = time.perf_counter()
calibration = self._calibration
border_pixels = extract_border_pixels(frame, calibration.border_width)
t1 = time.perf_counter()
mapper = self._pixel_mapper
led_colors = self._pixel_mapper.map_border_to_leds(border_pixels)
if isinstance(mapper, AdvancedPixelMapper):
# Advanced mode: gather frames from all live streams
frames_dict = {}
for ps_id, ls in self._live_streams.items():
f = ls.get_latest_frame()
if f is not None:
frames_dict[ps_id] = f
t1 = time.perf_counter()
led_colors = mapper.map_lines_to_leds(frames_dict)
else:
border_pixels = extract_border_pixels(frame, calibration.border_width)
t1 = time.perf_counter()
led_colors = mapper.map_border_to_leds(border_pixels)
t2 = time.perf_counter()
# Ensure scratch pool is sized for this frame

View File

@@ -42,12 +42,14 @@ class _ColorStripEntry:
stream: ColorStripStream
ref_count: int
# ID of the picture source whose LiveStream we acquired (for release)
picture_source_id: str
# IDs of picture sources whose LiveStreams we acquired (for release)
picture_source_ids: list = None
# Per-consumer target FPS values (target_id → fps)
target_fps: Dict[str, int] = None
def __post_init__(self):
if self.picture_source_ids is None:
self.picture_source_ids = []
if self.target_fps is None:
self.target_fps = {}
@@ -155,7 +157,7 @@ class ColorStripStreamManager:
css_stream.start()
key = f"{css_id}:{consumer_id}" if consumer_id else css_id
self._streams[key] = _ColorStripEntry(
stream=css_stream, ref_count=1, picture_source_id="",
stream=css_stream, ref_count=1, picture_source_ids=[],
)
logger.info(f"Created {source.source_type} stream {key}")
return css_stream
@@ -167,26 +169,47 @@ class ColorStripStreamManager:
logger.info(f"Reusing stream {css_id} (ref_count={entry.ref_count})")
return entry.stream
# Create new picture stream — needs a LiveStream from the capture pipeline
from wled_controller.storage.color_strip_source import PictureColorStripSource
if not isinstance(source, PictureColorStripSource):
# Create new picture stream — needs LiveStream(s) from the capture pipeline
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource
if not isinstance(source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
raise ValueError(
f"Unsupported sharable source type '{source.source_type}' for {css_id}"
)
if not source.picture_source_id:
raise ValueError(
f"Color strip source {css_id} has no picture_source_id assigned"
)
# Acquire the underlying live stream (ref-counted)
live_stream = self._live_stream_manager.acquire(source.picture_source_id)
# Determine required picture sources based on calibration mode
required_ps_ids = source.calibration.get_required_picture_source_ids()
if not required_ps_ids:
# Simple mode: use the CSS source's single picture_source_id
ps_id = getattr(source, "picture_source_id", "")
if not ps_id:
raise ValueError(
f"Color strip source {css_id} has no picture_source_id assigned"
)
required_ps_ids = [ps_id]
# Acquire all required live streams (with rollback on failure)
acquired = {}
try:
css_stream = PictureColorStripStream(live_stream, source)
for ps_id in required_ps_ids:
acquired[ps_id] = self._live_stream_manager.acquire(ps_id)
except Exception as e:
for ps_id in acquired:
self._live_stream_manager.release(ps_id)
raise ValueError(
f"Failed to acquire live streams for source {css_id}: {e}"
) from e
# Create stream (single LiveStream for simple, dict for advanced)
try:
if len(acquired) == 1 and source.calibration.mode == "simple":
live_arg = next(iter(acquired.values()))
else:
live_arg = acquired
css_stream = PictureColorStripStream(live_arg, source)
css_stream.start()
except Exception as e:
self._live_stream_manager.release(source.picture_source_id)
for ps_id in acquired:
self._live_stream_manager.release(ps_id)
raise RuntimeError(
f"Failed to start color strip stream for source {css_id}: {e}"
) from e
@@ -194,7 +217,7 @@ class ColorStripStreamManager:
self._streams[css_id] = _ColorStripEntry(
stream=css_stream,
ref_count=1,
picture_source_id=source.picture_source_id,
picture_source_ids=list(acquired.keys()),
)
logger.info(f"Created picture color strip stream {css_id}")
@@ -229,13 +252,13 @@ class ColorStripStreamManager:
source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream)
picture_source_id = entry.picture_source_id
picture_source_ids = entry.picture_source_ids
del self._streams[key]
logger.info(f"Removed color strip stream {key}")
# Release the underlying live stream (not needed for static sources)
if picture_source_id:
self._live_stream_manager.release(picture_source_id)
# Release all underlying live streams
for ps_id in picture_source_ids:
self._live_stream_manager.release(ps_id)
def update_source(self, css_id: str, new_source) -> None:
"""Hot-update processing params on all running streams for a source.
@@ -280,15 +303,19 @@ class ColorStripStreamManager:
source_id = key.split(":")[0] if ":" in key else key
self._release_clock(source_id, entry.stream)
# Track picture_source_id change for future reference counting
from wled_controller.storage.color_strip_source import PictureColorStripSource
if isinstance(new_source, PictureColorStripSource):
# Track picture source changes for future reference counting
from wled_controller.storage.color_strip_source import PictureColorStripSource, AdvancedPictureColorStripSource
if isinstance(new_source, (PictureColorStripSource, AdvancedPictureColorStripSource)):
new_ps_ids = new_source.calibration.get_required_picture_source_ids()
if not new_ps_ids:
ps_id = getattr(new_source, "picture_source_id", "")
new_ps_ids = [ps_id] if ps_id else []
for key in matching_keys:
entry = self._streams[key]
if new_source.picture_source_id != entry.picture_source_id:
if set(new_ps_ids) != set(entry.picture_source_ids):
logger.info(
f"CSS {css_id}: picture_source_id changed — "
f"restart target to use new source"
f"CSS {css_id}: picture source set changed — "
f"restart target to use new sources"
)
break