Move FPS from color strip source to target; dynamic capture rate

FPS is a consumption property (how fast to send to a device), not a
production property. Two targets sharing the same source may need
different FPS. This moves the fps field from PictureColorStripSource
to WledPictureTarget across the full stack.

The capture stream now auto-adjusts its rate to max(all connected
target FPS values) via ColorStripStreamManager tracking per-consumer
FPS. UI updates: FPS slider in target editor, FPS badge on target
cards, LED count repositioned in CSS editor, consistent speed icons.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 03:46:08 +03:00
parent 1204676c30
commit 1f6c913343
14 changed files with 126 additions and 57 deletions

View File

@@ -59,7 +59,6 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
name=source.name, name=source.name,
source_type=source.source_type, source_type=source.source_type,
picture_source_id=getattr(source, "picture_source_id", None), picture_source_id=getattr(source, "picture_source_id", None),
fps=getattr(source, "fps", None),
brightness=getattr(source, "brightness", None), brightness=getattr(source, "brightness", None),
saturation=getattr(source, "saturation", None), saturation=getattr(source, "saturation", None),
gamma=getattr(source, "gamma", None), gamma=getattr(source, "gamma", None),
@@ -127,7 +126,6 @@ async def create_color_strip_source(
name=data.name, name=data.name,
source_type=data.source_type, source_type=data.source_type,
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
fps=data.fps,
brightness=data.brightness, brightness=data.brightness,
saturation=data.saturation, saturation=data.saturation,
gamma=data.gamma, gamma=data.gamma,
@@ -187,7 +185,6 @@ async def update_color_strip_source(
source_id=source_id, source_id=source_id,
name=data.name, name=data.name,
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
fps=data.fps,
brightness=data.brightness, brightness=data.brightness,
saturation=data.saturation, saturation=data.saturation,
gamma=data.gamma, gamma=data.gamma,

View File

@@ -94,6 +94,7 @@ def _target_to_response(target) -> PictureTargetResponse:
target_type=target.target_type, target_type=target.target_type,
device_id=target.device_id, device_id=target.device_id,
color_strip_source_id=target.color_strip_source_id, color_strip_source_id=target.color_strip_source_id,
fps=target.fps,
standby_interval=target.standby_interval, standby_interval=target.standby_interval,
state_check_interval=target.state_check_interval, state_check_interval=target.state_check_interval,
description=target.description, description=target.description,
@@ -148,6 +149,7 @@ async def create_target(
target_type=data.target_type, target_type=data.target_type,
device_id=data.device_id, device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id, color_strip_source_id=data.color_strip_source_id,
fps=data.fps,
standby_interval=data.standby_interval, standby_interval=data.standby_interval,
state_check_interval=data.state_check_interval, state_check_interval=data.state_check_interval,
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
@@ -243,6 +245,7 @@ async def update_target(
name=data.name, name=data.name,
device_id=data.device_id, device_id=data.device_id,
color_strip_source_id=data.color_strip_source_id, color_strip_source_id=data.color_strip_source_id,
fps=data.fps,
standby_interval=data.standby_interval, standby_interval=data.standby_interval,
state_check_interval=data.state_check_interval, state_check_interval=data.state_check_interval,
picture_source_id=data.picture_source_id, picture_source_id=data.picture_source_id,
@@ -254,7 +257,8 @@ async def update_target(
try: try:
target.sync_with_manager( target.sync_with_manager(
manager, manager,
settings_changed=(data.standby_interval is not None or settings_changed=(data.fps is not None or
data.standby_interval is not None or
data.state_check_interval is not None or data.state_check_interval is not None or
data.key_colors_settings is not None), data.key_colors_settings is not None),
source_changed=data.color_strip_source_id is not None, source_changed=data.color_strip_source_id is not None,

View File

@@ -34,7 +34,6 @@ class ColorStripSourceCreate(BaseModel):
source_type: Literal["picture", "static", "gradient", "color_cycle"] = Field(default="picture", description="Source type") source_type: Literal["picture", "static", "gradient", "color_cycle"] = Field(default="picture", description="Source type")
# picture-type fields # picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0) saturation: float = Field(default=1.0, description="Saturation (0.0=grayscale, 1.0=unchanged, 2.0=double)", ge=0.0, le=2.0)
gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0) gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0)
@@ -61,7 +60,6 @@ class ColorStripSourceUpdate(BaseModel):
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
# picture-type fields # picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: Optional[str] = Field(None, description="Picture source ID")
fps: Optional[int] = Field(None, description="Target FPS", ge=10, le=90)
brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0) saturation: Optional[float] = Field(None, description="Saturation (0.0-2.0)", ge=0.0, le=2.0)
gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0) gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0)
@@ -90,7 +88,6 @@ class ColorStripSourceResponse(BaseModel):
source_type: str = Field(description="Source type") source_type: str = Field(description="Source type")
# picture-type fields # picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: Optional[str] = Field(None, description="Picture source ID")
fps: Optional[int] = Field(None, description="Target FPS")
brightness: Optional[float] = Field(None, description="Brightness multiplier") brightness: Optional[float] = Field(None, description="Brightness multiplier")
saturation: Optional[float] = Field(None, description="Saturation") saturation: Optional[float] = Field(None, description="Saturation")
gamma: Optional[float] = Field(None, description="Gamma correction") gamma: Optional[float] = Field(None, description="Gamma correction")

View File

@@ -53,6 +53,7 @@ class PictureTargetCreate(BaseModel):
# LED target fields # LED target fields
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") color_strip_source_id: str = Field(default="", description="Color strip source ID")
fps: int = Field(default=30, ge=10, le=90, description="Target send FPS (10-90)")
standby_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0) standby_interval: float = Field(default=1.0, description="Keepalive send interval when screen is static (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600) state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
# KC target fields # KC target fields
@@ -68,6 +69,7 @@ class PictureTargetUpdate(BaseModel):
# LED target fields # LED target fields
device_id: Optional[str] = Field(None, description="LED device ID") device_id: Optional[str] = Field(None, description="LED device ID")
color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID") color_strip_source_id: Optional[str] = Field(None, description="Color strip source ID")
fps: Optional[int] = Field(None, ge=10, le=90, description="Target send FPS (10-90)")
standby_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0) standby_interval: Optional[float] = Field(None, description="Keepalive interval (0.5-5.0s)", ge=0.5, le=5.0)
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600) state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
# KC target fields # KC target fields
@@ -85,6 +87,7 @@ class PictureTargetResponse(BaseModel):
# LED target fields # LED target fields
device_id: str = Field(default="", description="LED device ID") device_id: str = Field(default="", description="LED device ID")
color_strip_source_id: str = Field(default="", description="Color strip source ID") color_strip_source_id: str = Field(default="", description="Color strip source ID")
fps: Optional[int] = Field(None, description="Target send FPS")
standby_interval: float = Field(default=1.0, description="Keepalive interval (s)") standby_interval: float = Field(default=1.0, description="Keepalive interval (s)")
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)") state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
# KC target fields # KC target fields

View File

@@ -135,7 +135,7 @@ class PictureColorStripStream(ColorStripStream):
from wled_controller.storage.color_strip_source import PictureColorStripSource from wled_controller.storage.color_strip_source import PictureColorStripSource
self._live_stream = live_stream self._live_stream = live_stream
self._fps: int = source.fps self._fps: int = 30 # internal capture rate (send FPS is on the target)
self._smoothing: float = source.smoothing self._smoothing: float = source.smoothing
self._brightness: float = source.brightness self._brightness: float = source.brightness
self._saturation: float = source.saturation self._saturation: float = source.saturation
@@ -217,6 +217,14 @@ class PictureColorStripStream(ColorStripStream):
def get_last_timing(self) -> dict: def get_last_timing(self) -> dict:
return dict(self._last_timing) return dict(self._last_timing)
def set_capture_fps(self, fps: int) -> None:
"""Update the internal capture rate. Thread-safe (read atomically by the loop)."""
fps = max(10, min(90, fps))
if fps != self._fps:
self._fps = fps
self._interp_duration = 1.0 / fps
logger.info(f"PictureColorStripStream capture FPS set to {fps}")
def update_source(self, source) -> None: def update_source(self, source) -> None:
"""Hot-update processing parameters. Thread-safe for scalar params. """Hot-update processing parameters. Thread-safe for scalar params.
@@ -227,7 +235,6 @@ class PictureColorStripStream(ColorStripStream):
if not isinstance(source, PictureColorStripSource): if not isinstance(source, PictureColorStripSource):
return return
self._fps = source.fps
self._smoothing = source.smoothing self._smoothing = source.smoothing
self._brightness = source.brightness self._brightness = source.brightness
self._saturation = source.saturation self._saturation = source.saturation

View File

@@ -32,6 +32,12 @@ class _ColorStripEntry:
ref_count: int ref_count: int
# ID of the picture source whose LiveStream we acquired (for release) # ID of the picture source whose LiveStream we acquired (for release)
picture_source_id: str picture_source_id: str
# Per-consumer target FPS values (target_id → fps)
target_fps: Dict[str, int] = None
def __post_init__(self):
if self.target_fps is None:
self.target_fps = {}
class ColorStripStreamManager: class ColorStripStreamManager:
@@ -213,6 +219,36 @@ class ColorStripStreamManager:
logger.info(f"Updated running color strip stream {css_id}") logger.info(f"Updated running color strip stream {css_id}")
def notify_target_fps(self, css_id: str, target_id: str, fps: int) -> None:
"""Register or update a consumer's target FPS.
Recalculates the capture rate for PictureColorStripStreams as
max(all consumer FPS values). Non-picture streams are unaffected.
"""
entry = self._streams.get(css_id)
if not entry:
return
entry.target_fps[target_id] = fps
self._recalc_capture_fps(entry)
def remove_target_fps(self, css_id: str, target_id: str) -> None:
"""Unregister a consumer's target FPS (e.g. on stop)."""
entry = self._streams.get(css_id)
if not entry:
return
entry.target_fps.pop(target_id, None)
self._recalc_capture_fps(entry)
def _recalc_capture_fps(self, entry: _ColorStripEntry) -> None:
"""Push max(consumer FPS) to the stream if it supports set_capture_fps."""
if not hasattr(entry.stream, "set_capture_fps"):
return
if entry.target_fps:
new_fps = max(entry.target_fps.values())
else:
new_fps = 30 # default when no consumers
entry.stream.set_capture_fps(new_fps)
def release_all(self) -> None: def release_all(self) -> None:
"""Stop and remove all managed color strip streams. Called on shutdown.""" """Stop and remove all managed color strip streams. Called on shutdown."""
css_ids = list(self._streams.keys()) css_ids = list(self._streams.keys())

View File

@@ -272,6 +272,7 @@ class ProcessorManager:
target_id: str, target_id: str,
device_id: str, device_id: str,
color_strip_source_id: str = "", color_strip_source_id: str = "",
fps: int = 30,
standby_interval: float = 1.0, standby_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
): ):
@@ -285,6 +286,7 @@ class ProcessorManager:
target_id=target_id, target_id=target_id,
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
fps=fps,
standby_interval=standby_interval, standby_interval=standby_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
ctx=self._build_context(), ctx=self._build_context(),

View File

@@ -40,6 +40,7 @@ class WledTargetProcessor(TargetProcessor):
target_id: str, target_id: str,
device_id: str, device_id: str,
color_strip_source_id: str, color_strip_source_id: str,
fps: int,
standby_interval: float, standby_interval: float,
state_check_interval: int, state_check_interval: int,
ctx: TargetContext, ctx: TargetContext,
@@ -47,6 +48,7 @@ class WledTargetProcessor(TargetProcessor):
super().__init__(target_id, ctx) super().__init__(target_id, ctx)
self._device_id = device_id self._device_id = device_id
self._color_strip_source_id = color_strip_source_id self._color_strip_source_id = color_strip_source_id
self._target_fps = fps if fps > 0 else 30
self._standby_interval = standby_interval self._standby_interval = standby_interval
self._state_check_interval = state_check_interval self._state_check_interval = state_check_interval
@@ -58,7 +60,6 @@ class WledTargetProcessor(TargetProcessor):
# Resolved stream metadata (set once stream is acquired) # Resolved stream metadata (set once stream is acquired)
self._resolved_display_index: Optional[int] = None self._resolved_display_index: Optional[int] = None
self._resolved_target_fps: Optional[int] = None
# ----- Properties ----- # ----- Properties -----
@@ -114,7 +115,6 @@ class WledTargetProcessor(TargetProcessor):
stream = await asyncio.to_thread(css_manager.acquire, self._color_strip_source_id) stream = await asyncio.to_thread(css_manager.acquire, self._color_strip_source_id)
self._color_strip_stream = stream self._color_strip_stream = stream
self._resolved_display_index = stream.display_index self._resolved_display_index = stream.display_index
self._resolved_target_fps = stream.target_fps
# For auto-sized static/gradient/color_cycle streams (led_count == 0), size to device LED count # For auto-sized static/gradient/color_cycle streams (led_count == 0), size to device LED count
from wled_controller.core.processing.color_strip_stream import ( from wled_controller.core.processing.color_strip_stream import (
@@ -125,10 +125,15 @@ class WledTargetProcessor(TargetProcessor):
if isinstance(stream, (StaticColorStripStream, GradientColorStripStream, ColorCycleColorStripStream)) and device_info.led_count > 0: if isinstance(stream, (StaticColorStripStream, GradientColorStripStream, ColorCycleColorStripStream)) and device_info.led_count > 0:
stream.configure(device_info.led_count) stream.configure(device_info.led_count)
# Notify stream manager of our target FPS so it can adjust capture rate
css_manager.notify_target_fps(
self._color_strip_source_id, self._target_id, self._target_fps
)
logger.info( logger.info(
f"Acquired color strip stream for target {self._target_id} " f"Acquired color strip stream for target {self._target_id} "
f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, " f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, "
f"fps={self._resolved_target_fps})" f"fps={self._target_fps})"
) )
except Exception as e: except Exception as e:
logger.error(f"Failed to acquire color strip stream for target {self._target_id}: {e}") logger.error(f"Failed to acquire color strip stream for target {self._target_id}: {e}")
@@ -176,6 +181,7 @@ class WledTargetProcessor(TargetProcessor):
css_manager = self._ctx.color_strip_stream_manager css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._color_strip_source_id: if css_manager and self._color_strip_source_id:
try: try:
css_manager.remove_target_fps(self._color_strip_source_id, self._target_id)
await asyncio.to_thread(css_manager.release, self._color_strip_source_id) await asyncio.to_thread(css_manager.release, self._color_strip_source_id)
except Exception as e: except Exception as e:
logger.warning(f"Error releasing color strip stream for {self._target_id}: {e}") logger.warning(f"Error releasing color strip stream for {self._target_id}: {e}")
@@ -189,6 +195,14 @@ class WledTargetProcessor(TargetProcessor):
def update_settings(self, settings: dict) -> None: def update_settings(self, settings: dict) -> None:
"""Update target-specific timing settings.""" """Update target-specific timing settings."""
if isinstance(settings, dict): if isinstance(settings, dict):
if "fps" in settings:
self._target_fps = settings["fps"] if settings["fps"] > 0 else 30
# Notify stream manager so capture rate adjusts to max of all consumers
css_manager = self._ctx.color_strip_stream_manager
if css_manager and self._color_strip_source_id and self._is_running:
css_manager.notify_target_fps(
self._color_strip_source_id, self._target_id, self._target_fps
)
if "standby_interval" in settings: if "standby_interval" in settings:
self._standby_interval = settings["standby_interval"] self._standby_interval = settings["standby_interval"]
if "state_check_interval" in settings: if "state_check_interval" in settings:
@@ -213,11 +227,12 @@ class WledTargetProcessor(TargetProcessor):
old_id = self._color_strip_source_id old_id = self._color_strip_source_id
try: try:
new_stream = css_manager.acquire(color_strip_source_id) new_stream = css_manager.acquire(color_strip_source_id)
css_manager.remove_target_fps(old_id, self._target_id)
css_manager.release(old_id) css_manager.release(old_id)
self._color_strip_stream = new_stream self._color_strip_stream = new_stream
self._resolved_display_index = new_stream.display_index self._resolved_display_index = new_stream.display_index
self._resolved_target_fps = new_stream.target_fps
self._color_strip_source_id = color_strip_source_id self._color_strip_source_id = color_strip_source_id
css_manager.notify_target_fps(color_strip_source_id, self._target_id, self._target_fps)
logger.info(f"Swapped color strip source for {self._target_id}: {old_id}{color_strip_source_id}") logger.info(f"Swapped color strip source for {self._target_id}: {old_id}{color_strip_source_id}")
except Exception as e: except Exception as e:
logger.error(f"Failed to swap color strip source for {self._target_id}: {e}") logger.error(f"Failed to swap color strip source for {self._target_id}: {e}")
@@ -234,7 +249,7 @@ class WledTargetProcessor(TargetProcessor):
def get_state(self) -> dict: def get_state(self) -> dict:
metrics = self._metrics metrics = self._metrics
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None fps_target = self._target_fps
# Pull per-stage timing from the CSS stream (runs in a background thread) # Pull per-stage timing from the CSS stream (runs in a background thread)
css_timing: dict = {} css_timing: dict = {}
@@ -277,7 +292,7 @@ class WledTargetProcessor(TargetProcessor):
def get_metrics(self) -> dict: def get_metrics(self) -> dict:
metrics = self._metrics metrics = self._metrics
fps_target = self._color_strip_stream.target_fps if self._color_strip_stream else None fps_target = self._target_fps
uptime_seconds = 0.0 uptime_seconds = 0.0
if metrics.start_time and self._is_running: if metrics.start_time and self._is_running:
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds() uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
@@ -406,15 +421,15 @@ class WledTargetProcessor(TargetProcessor):
logger.info( logger.info(
f"Processing loop started for target {self._target_id} " f"Processing loop started for target {self._target_id} "
f"(display={self._resolved_display_index}, fps={self._resolved_target_fps})" f"(display={self._resolved_display_index}, fps={self._target_fps})"
) )
try: try:
with high_resolution_timer(): with high_resolution_timer():
while self._is_running: while self._is_running:
loop_start = now = time.perf_counter() loop_start = now = time.perf_counter()
# Re-read target_fps each tick so hot-updates to the CSS source take effect # Re-read target_fps each tick so hot-updates take effect immediately
target_fps = stream.target_fps if stream.target_fps > 0 else 30 target_fps = self._target_fps if self._target_fps > 0 else 30
frame_time = 1.0 / target_fps frame_time = 1.0 / target_fps
# Re-fetch device info every ~30 iterations instead of every # Re-fetch device info every ~30 iterations instead of every

View File

@@ -18,7 +18,6 @@ class CSSEditorModal extends Modal {
name: document.getElementById('css-editor-name').value, name: document.getElementById('css-editor-name').value,
type, type,
picture_source: document.getElementById('css-editor-picture-source').value, picture_source: document.getElementById('css-editor-picture-source').value,
fps: document.getElementById('css-editor-fps').value,
interpolation: document.getElementById('css-editor-interpolation').value, interpolation: document.getElementById('css-editor-interpolation').value,
smoothing: document.getElementById('css-editor-smoothing').value, smoothing: document.getElementById('css-editor-smoothing').value,
brightness: document.getElementById('css-editor-brightness').value, brightness: document.getElementById('css-editor-brightness').value,
@@ -26,7 +25,7 @@ class CSSEditorModal extends Modal {
gamma: document.getElementById('css-editor-gamma').value, gamma: document.getElementById('css-editor-gamma').value,
color: document.getElementById('css-editor-color').value, color: document.getElementById('css-editor-color').value,
frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked, frame_interpolation: document.getElementById('css-editor-frame-interpolation').checked,
led_count: (type === 'static' || type === 'gradient' || type === 'color_cycle') ? '0' : document.getElementById('css-editor-led-count').value, led_count: document.getElementById('css-editor-led-count').value,
gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]', gradient_stops: type === 'gradient' ? JSON.stringify(_gradientStops) : '[]',
animation_enabled: document.getElementById('css-editor-animation-enabled').checked, animation_enabled: document.getElementById('css-editor-animation-enabled').checked,
animation_type: document.getElementById('css-editor-animation-type').value, animation_type: document.getElementById('css-editor-animation-type').value,
@@ -47,9 +46,6 @@ export function onCSSTypeChange() {
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none'; document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none'; document.getElementById('css-editor-color-cycle-section').style.display = type === 'color_cycle' ? '' : 'none';
document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none'; document.getElementById('css-editor-gradient-section').style.display = type === 'gradient' ? '' : 'none';
// LED count is only meaningful for picture sources; static/gradient/color_cycle auto-size from device
document.getElementById('css-editor-led-count-group').style.display =
(type === 'static' || type === 'gradient' || type === 'color_cycle') ? 'none' : '';
// Animation section — shown for static/gradient only (color_cycle is always animating) // Animation section — shown for static/gradient only (color_cycle is always animating)
const animSection = document.getElementById('css-editor-animation-section'); const animSection = document.getElementById('css-editor-animation-section');
@@ -205,7 +201,7 @@ export function createColorStripCard(source, pictureSourceMap) {
).join(''); ).join('');
propsHtml = ` propsHtml = `
<span class="stream-card-prop">${swatches}</span> <span class="stream-card-prop">${swatches}</span>
<span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}">🔄 ${(source.cycle_speed || 1.0).toFixed(1)}×</span> <span class="stream-card-prop" title="${t('color_strip.color_cycle.speed')}"> ${(source.cycle_speed || 1.0).toFixed(1)}×</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''} ${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`; `;
} else if (isGradient) { } else if (isGradient) {
@@ -227,6 +223,7 @@ export function createColorStripCard(source, pictureSourceMap) {
propsHtml = ` propsHtml = `
${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''} ${cssGradient ? `<span style="flex:1 1 100%;height:12px;background:${cssGradient};border-radius:3px;border:1px solid rgba(128,128,128,0.3)"></span>` : ''}
<span class="stream-card-prop">🎨 ${stops.length} ${t('color_strip.gradient.stops_count')}</span> <span class="stream-card-prop">🎨 ${stops.length} ${t('color_strip.gradient.stops_count')}</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
${animBadge} ${animBadge}
`; `;
} else { } else {
@@ -237,9 +234,8 @@ export function createColorStripCard(source, pictureSourceMap) {
const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
const ledCount = (source.led_count > 0) ? source.led_count : calLeds; const ledCount = (source.led_count > 0) ? source.led_count : calLeds;
propsHtml = ` propsHtml = `
<span class="stream-card-prop" title="${t('color_strip.fps')}">⚡ ${source.fps || 30} fps</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span> <span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
`; `;
} }
@@ -311,10 +307,6 @@ export async function showCSSEditor(cssId = null) {
} else { } else {
sourceSelect.value = css.picture_source_id || ''; sourceSelect.value = css.picture_source_id || '';
const fps = css.fps ?? 30;
document.getElementById('css-editor-fps').value = fps;
document.getElementById('css-editor-fps-value').textContent = fps;
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average'; document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
const smoothing = css.smoothing ?? 0.3; const smoothing = css.smoothing ?? 0.3;
@@ -343,8 +335,6 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-name').value = ''; document.getElementById('css-editor-name').value = '';
document.getElementById('css-editor-type').value = 'picture'; document.getElementById('css-editor-type').value = 'picture';
onCSSTypeChange(); onCSSTypeChange();
document.getElementById('css-editor-fps').value = 30;
document.getElementById('css-editor-fps-value').textContent = '30';
document.getElementById('css-editor-interpolation').value = 'average'; document.getElementById('css-editor-interpolation').value = 'average';
document.getElementById('css-editor-smoothing').value = 0.3; document.getElementById('css-editor-smoothing').value = 0.3;
document.getElementById('css-editor-smoothing-value').textContent = '0.30'; document.getElementById('css-editor-smoothing-value').textContent = '0.30';
@@ -432,7 +422,6 @@ export async function saveCSSEditor() {
payload = { payload = {
name, name,
picture_source_id: document.getElementById('css-editor-picture-source').value, picture_source_id: document.getElementById('css-editor-picture-source').value,
fps: parseInt(document.getElementById('css-editor-fps').value) || 30,
interpolation_mode: document.getElementById('css-editor-interpolation').value, interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value), smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
brightness: parseFloat(document.getElementById('css-editor-brightness').value), brightness: parseFloat(document.getElementById('css-editor-brightness').value),

View File

@@ -72,6 +72,7 @@ class TargetEditorModal extends Modal {
name: document.getElementById('target-editor-name').value, name: document.getElementById('target-editor-name').value,
device: document.getElementById('target-editor-device').value, device: document.getElementById('target-editor-device').value,
css: document.getElementById('target-editor-css').value, css: document.getElementById('target-editor-css').value,
fps: document.getElementById('target-editor-fps').value,
standby_interval: document.getElementById('target-editor-standby-interval').value, standby_interval: document.getElementById('target-editor-standby-interval').value,
}; };
} }
@@ -146,6 +147,9 @@ export async function showTargetEditor(targetId = null) {
document.getElementById('target-editor-name').value = target.name; document.getElementById('target-editor-name').value = target.name;
deviceSelect.value = target.device_id || ''; deviceSelect.value = target.device_id || '';
cssSelect.value = target.color_strip_source_id || ''; cssSelect.value = target.color_strip_source_id || '';
const fps = target.fps ?? 30;
document.getElementById('target-editor-fps').value = fps;
document.getElementById('target-editor-fps-value').textContent = fps;
document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0; document.getElementById('target-editor-standby-interval').value = target.standby_interval ?? 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0; document.getElementById('target-editor-standby-interval-value').textContent = target.standby_interval ?? 1.0;
document.getElementById('target-editor-title').textContent = t('targets.edit'); document.getElementById('target-editor-title').textContent = t('targets.edit');
@@ -153,6 +157,8 @@ export async function showTargetEditor(targetId = null) {
// Creating new target — first option is selected by default // Creating new target — first option is selected by default
document.getElementById('target-editor-id').value = ''; document.getElementById('target-editor-id').value = '';
document.getElementById('target-editor-name').value = ''; document.getElementById('target-editor-name').value = '';
document.getElementById('target-editor-fps').value = 30;
document.getElementById('target-editor-fps-value').textContent = '30';
document.getElementById('target-editor-standby-interval').value = 1.0; document.getElementById('target-editor-standby-interval').value = 1.0;
document.getElementById('target-editor-standby-interval-value').textContent = '1.0'; document.getElementById('target-editor-standby-interval-value').textContent = '1.0';
document.getElementById('target-editor-title').textContent = t('targets.add'); document.getElementById('target-editor-title').textContent = t('targets.add');
@@ -203,10 +209,13 @@ export async function saveTargetEditor() {
return; return;
} }
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
const payload = { const payload = {
name, name,
device_id: deviceId, device_id: deviceId,
color_strip_source_id: cssId, color_strip_source_id: cssId,
fps,
standby_interval: standbyInterval, standby_interval: standbyInterval,
}; };
@@ -508,6 +517,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
</div> </div>
<div class="stream-card-props"> <div class="stream-card-props">
<span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span> <span class="stream-card-prop" title="${t('targets.device')}">💡 ${escapeHtml(deviceName)}</span>
<span class="stream-card-prop" title="${t('targets.fps')}">⚡ ${target.fps || 30} fps</span>
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span> <span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span>
</div> </div>
<div class="card-content"> <div class="card-content">

View File

@@ -102,6 +102,7 @@ class PictureTargetStore:
target_type: str, target_type: str,
device_id: str = "", device_id: str = "",
color_strip_source_id: str = "", color_strip_source_id: str = "",
fps: int = 30,
standby_interval: float = 1.0, standby_interval: float = 1.0,
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL, state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
key_colors_settings: Optional[KeyColorsSettings] = None, key_colors_settings: Optional[KeyColorsSettings] = None,
@@ -146,6 +147,7 @@ class PictureTargetStore:
target_type="led", target_type="led",
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
fps=fps,
standby_interval=standby_interval, standby_interval=standby_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
description=description, description=description,
@@ -178,6 +180,7 @@ class PictureTargetStore:
name: Optional[str] = None, name: Optional[str] = None,
device_id: Optional[str] = None, device_id: Optional[str] = None,
color_strip_source_id: Optional[str] = None, color_strip_source_id: Optional[str] = None,
fps: Optional[int] = None,
standby_interval: Optional[float] = None, standby_interval: Optional[float] = None,
state_check_interval: Optional[int] = None, state_check_interval: Optional[int] = None,
key_colors_settings: Optional[KeyColorsSettings] = None, key_colors_settings: Optional[KeyColorsSettings] = None,
@@ -206,6 +209,7 @@ class PictureTargetStore:
name=name, name=name,
device_id=device_id, device_id=device_id,
color_strip_source_id=color_strip_source_id, color_strip_source_id=color_strip_source_id,
fps=fps,
standby_interval=standby_interval, standby_interval=standby_interval,
state_check_interval=state_check_interval, state_check_interval=state_check_interval,
key_colors_settings=key_colors_settings, key_colors_settings=key_colors_settings,

View File

@@ -13,13 +13,13 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
class WledPictureTarget(PictureTarget): class WledPictureTarget(PictureTarget):
"""LED picture target — pairs an LED device with a ColorStripSource. """LED picture target — pairs an LED device with a ColorStripSource.
The ColorStripSource encapsulates everything needed to produce LED colors The ColorStripSource produces LED colors (calibration, color correction,
(calibration, color correction, smoothing, fps). The LED target itself only smoothing). The target controls device-specific settings including send FPS.
holds device-specific timing/keepalive settings.
""" """
device_id: str = "" device_id: str = ""
color_strip_source_id: str = "" color_strip_source_id: str = ""
fps: int = 30 # target send FPS (10-90)
standby_interval: float = 1.0 # seconds between keepalive sends when screen is static standby_interval: float = 1.0 # seconds between keepalive sends when screen is static
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
@@ -34,6 +34,7 @@ class WledPictureTarget(PictureTarget):
target_id=self.id, target_id=self.id,
device_id=self.device_id, device_id=self.device_id,
color_strip_source_id=self.color_strip_source_id, color_strip_source_id=self.color_strip_source_id,
fps=self.fps,
standby_interval=self.standby_interval, standby_interval=self.standby_interval,
state_check_interval=self.state_check_interval, state_check_interval=self.state_check_interval,
) )
@@ -42,6 +43,7 @@ class WledPictureTarget(PictureTarget):
"""Push changed fields to the processor manager.""" """Push changed fields to the processor manager."""
if settings_changed: if settings_changed:
manager.update_target_settings(self.id, { manager.update_target_settings(self.id, {
"fps": self.fps,
"standby_interval": self.standby_interval, "standby_interval": self.standby_interval,
"state_check_interval": self.state_check_interval, "state_check_interval": self.state_check_interval,
}) })
@@ -51,7 +53,7 @@ class WledPictureTarget(PictureTarget):
manager.update_target_device(self.id, self.device_id) manager.update_target_device(self.id, self.device_id)
def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None, def update_fields(self, *, name=None, device_id=None, color_strip_source_id=None,
standby_interval=None, state_check_interval=None, fps=None, standby_interval=None, state_check_interval=None,
# Legacy params accepted but ignored to keep base class compat: # Legacy params accepted but ignored to keep base class compat:
picture_source_id=None, settings=None, picture_source_id=None, settings=None,
key_colors_settings=None, description=None) -> None: key_colors_settings=None, description=None) -> None:
@@ -61,6 +63,8 @@ class WledPictureTarget(PictureTarget):
self.device_id = device_id self.device_id = device_id
if color_strip_source_id is not None: if color_strip_source_id is not None:
self.color_strip_source_id = color_strip_source_id self.color_strip_source_id = color_strip_source_id
if fps is not None:
self.fps = fps
if standby_interval is not None: if standby_interval is not None:
self.standby_interval = standby_interval self.standby_interval = standby_interval
if state_check_interval is not None: if state_check_interval is not None:
@@ -75,6 +79,7 @@ class WledPictureTarget(PictureTarget):
d = super().to_dict() d = super().to_dict()
d["device_id"] = self.device_id d["device_id"] = self.device_id
d["color_strip_source_id"] = self.color_strip_source_id d["color_strip_source_id"] = self.color_strip_source_id
d["fps"] = self.fps
d["standby_interval"] = self.standby_interval d["standby_interval"] = self.standby_interval
d["state_check_interval"] = self.state_check_interval d["state_check_interval"] = self.state_check_interval
return d return d
@@ -88,6 +93,7 @@ class WledPictureTarget(PictureTarget):
target_type="led", target_type="led",
device_id=data.get("device_id", ""), device_id=data.get("device_id", ""),
color_strip_source_id=data.get("color_strip_source_id", ""), color_strip_source_id=data.get("color_strip_source_id", ""),
fps=data.get("fps", 30),
standby_interval=data.get("standby_interval", 1.0), standby_interval=data.get("standby_interval", 1.0),
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL), state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
description=data.get("description"), description=data.get("description"),

View File

@@ -39,19 +39,13 @@
<select id="css-editor-picture-source"></select> <select id="css-editor-picture-source"></select>
</div> </div>
<div class="form-group"> <div id="css-editor-led-count-group" class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-fps"> <label for="css-editor-led-count" data-i18n="color_strip.led_count">LED Count:</label>
<span data-i18n="color_strip.fps">Target FPS:</span>
<span id="css-editor-fps-value">30</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.fps.hint">Target frames per second for LED color updates (10-90)</small> <small class="input-hint" style="display:none" data-i18n="color_strip.led_count.hint">Total number of LEDs on the strip. Set to 0 to use the sum from calibration. If your strip has LEDs behind the TV that are not mapped to screen edges, set the exact count here and they will be filled with black.</small>
<div class="slider-row"> <input type="number" id="css-editor-led-count" min="0" max="1500" step="1" value="0">
<input type="range" id="css-editor-fps" min="10" max="90" value="30" oninput="document.getElementById('css-editor-fps-value').textContent = this.value">
<span class="slider-value">fps</span>
</div>
</div> </div>
<div class="form-group"> <div class="form-group">
@@ -236,16 +230,6 @@
</details> </details>
</div> </div>
<!-- LED count — picture type only (auto-sized from device for static/gradient) -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row">
<label for="css-editor-led-count" data-i18n="color_strip.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.led_count.hint">Total number of LEDs on the strip. Set to 0 to use the sum from calibration. If your strip has LEDs behind the TV that are not mapped to screen edges, set the exact count here and they will be filled with black.</small>
<input type="number" id="css-editor-led-count" min="0" max="1500" step="1" value="0">
</div>
<div id="css-editor-error" class="error-message" style="display: none;"></div> <div id="css-editor-error" class="error-message" style="display: none;"></div>
</form> </form>
</div> </div>

View File

@@ -32,6 +32,21 @@
<select id="target-editor-css"></select> <select id="target-editor-css"></select>
</div> </div>
<div class="form-group" id="target-editor-fps-group">
<div class="label-row">
<label for="target-editor-fps">
<span data-i18n="targets.fps">Target FPS:</span>
<span id="target-editor-fps-value">30</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="targets.fps.hint">How many frames per second to send to the device (10-90). Higher values give smoother animations but use more bandwidth.</small>
<div class="slider-row">
<input type="range" id="target-editor-fps" min="10" max="90" value="30" oninput="document.getElementById('target-editor-fps-value').textContent = this.value">
<span class="slider-value">fps</span>
</div>
</div>
<div class="form-group" id="target-editor-standby-group"> <div class="form-group" id="target-editor-standby-group">
<div class="label-row"> <div class="label-row">
<label for="target-editor-standby-interval"> <label for="target-editor-standby-interval">