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:
@@ -59,7 +59,6 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
|
||||
name=source.name,
|
||||
source_type=source.source_type,
|
||||
picture_source_id=getattr(source, "picture_source_id", None),
|
||||
fps=getattr(source, "fps", None),
|
||||
brightness=getattr(source, "brightness", None),
|
||||
saturation=getattr(source, "saturation", None),
|
||||
gamma=getattr(source, "gamma", None),
|
||||
@@ -127,7 +126,6 @@ async def create_color_strip_source(
|
||||
name=data.name,
|
||||
source_type=data.source_type,
|
||||
picture_source_id=data.picture_source_id,
|
||||
fps=data.fps,
|
||||
brightness=data.brightness,
|
||||
saturation=data.saturation,
|
||||
gamma=data.gamma,
|
||||
@@ -187,7 +185,6 @@ async def update_color_strip_source(
|
||||
source_id=source_id,
|
||||
name=data.name,
|
||||
picture_source_id=data.picture_source_id,
|
||||
fps=data.fps,
|
||||
brightness=data.brightness,
|
||||
saturation=data.saturation,
|
||||
gamma=data.gamma,
|
||||
|
||||
@@ -94,6 +94,7 @@ def _target_to_response(target) -> PictureTargetResponse:
|
||||
target_type=target.target_type,
|
||||
device_id=target.device_id,
|
||||
color_strip_source_id=target.color_strip_source_id,
|
||||
fps=target.fps,
|
||||
standby_interval=target.standby_interval,
|
||||
state_check_interval=target.state_check_interval,
|
||||
description=target.description,
|
||||
@@ -148,6 +149,7 @@ async def create_target(
|
||||
target_type=data.target_type,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
fps=data.fps,
|
||||
standby_interval=data.standby_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
picture_source_id=data.picture_source_id,
|
||||
@@ -243,6 +245,7 @@ async def update_target(
|
||||
name=data.name,
|
||||
device_id=data.device_id,
|
||||
color_strip_source_id=data.color_strip_source_id,
|
||||
fps=data.fps,
|
||||
standby_interval=data.standby_interval,
|
||||
state_check_interval=data.state_check_interval,
|
||||
picture_source_id=data.picture_source_id,
|
||||
@@ -254,7 +257,8 @@ async def update_target(
|
||||
try:
|
||||
target.sync_with_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.key_colors_settings is not None),
|
||||
source_changed=data.color_strip_source_id is not None,
|
||||
|
||||
@@ -34,7 +34,6 @@ class ColorStripSourceCreate(BaseModel):
|
||||
source_type: Literal["picture", "static", "gradient", "color_cycle"] = Field(default="picture", description="Source type")
|
||||
# picture-type fields
|
||||
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)
|
||||
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)
|
||||
@@ -61,7 +60,6 @@ class ColorStripSourceUpdate(BaseModel):
|
||||
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
|
||||
# picture-type fields
|
||||
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)
|
||||
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)
|
||||
@@ -90,7 +88,6 @@ class ColorStripSourceResponse(BaseModel):
|
||||
source_type: str = Field(description="Source type")
|
||||
# picture-type fields
|
||||
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")
|
||||
saturation: Optional[float] = Field(None, description="Saturation")
|
||||
gamma: Optional[float] = Field(None, description="Gamma correction")
|
||||
|
||||
@@ -53,6 +53,7 @@ class PictureTargetCreate(BaseModel):
|
||||
# LED target fields
|
||||
device_id: str = Field(default="", description="LED device 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)
|
||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Device health check interval (5-600s)", ge=5, le=600)
|
||||
# KC target fields
|
||||
@@ -68,6 +69,7 @@ class PictureTargetUpdate(BaseModel):
|
||||
# LED target fields
|
||||
device_id: Optional[str] = Field(None, description="LED device 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)
|
||||
state_check_interval: Optional[int] = Field(None, description="Health check interval (5-600s)", ge=5, le=600)
|
||||
# KC target fields
|
||||
@@ -85,6 +87,7 @@ class PictureTargetResponse(BaseModel):
|
||||
# LED target fields
|
||||
device_id: str = Field(default="", description="LED device 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)")
|
||||
state_check_interval: int = Field(default=DEFAULT_STATE_CHECK_INTERVAL, description="Health check interval (s)")
|
||||
# KC target fields
|
||||
|
||||
@@ -135,7 +135,7 @@ class PictureColorStripStream(ColorStripStream):
|
||||
from wled_controller.storage.color_strip_source import PictureColorStripSource
|
||||
|
||||
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._brightness: float = source.brightness
|
||||
self._saturation: float = source.saturation
|
||||
@@ -217,6 +217,14 @@ class PictureColorStripStream(ColorStripStream):
|
||||
def get_last_timing(self) -> dict:
|
||||
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:
|
||||
"""Hot-update processing parameters. Thread-safe for scalar params.
|
||||
|
||||
@@ -227,7 +235,6 @@ class PictureColorStripStream(ColorStripStream):
|
||||
if not isinstance(source, PictureColorStripSource):
|
||||
return
|
||||
|
||||
self._fps = source.fps
|
||||
self._smoothing = source.smoothing
|
||||
self._brightness = source.brightness
|
||||
self._saturation = source.saturation
|
||||
|
||||
@@ -32,6 +32,12 @@ class _ColorStripEntry:
|
||||
ref_count: int
|
||||
# ID of the picture source whose LiveStream we acquired (for release)
|
||||
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:
|
||||
@@ -213,6 +219,36 @@ class ColorStripStreamManager:
|
||||
|
||||
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:
|
||||
"""Stop and remove all managed color strip streams. Called on shutdown."""
|
||||
css_ids = list(self._streams.keys())
|
||||
|
||||
@@ -272,6 +272,7 @@ class ProcessorManager:
|
||||
target_id: str,
|
||||
device_id: str,
|
||||
color_strip_source_id: str = "",
|
||||
fps: int = 30,
|
||||
standby_interval: float = 1.0,
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||
):
|
||||
@@ -285,6 +286,7 @@ class ProcessorManager:
|
||||
target_id=target_id,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
fps=fps,
|
||||
standby_interval=standby_interval,
|
||||
state_check_interval=state_check_interval,
|
||||
ctx=self._build_context(),
|
||||
|
||||
@@ -40,6 +40,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
target_id: str,
|
||||
device_id: str,
|
||||
color_strip_source_id: str,
|
||||
fps: int,
|
||||
standby_interval: float,
|
||||
state_check_interval: int,
|
||||
ctx: TargetContext,
|
||||
@@ -47,6 +48,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
super().__init__(target_id, ctx)
|
||||
self._device_id = device_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._state_check_interval = state_check_interval
|
||||
|
||||
@@ -58,7 +60,6 @@ class WledTargetProcessor(TargetProcessor):
|
||||
|
||||
# Resolved stream metadata (set once stream is acquired)
|
||||
self._resolved_display_index: Optional[int] = None
|
||||
self._resolved_target_fps: Optional[int] = None
|
||||
|
||||
# ----- Properties -----
|
||||
|
||||
@@ -114,7 +115,6 @@ class WledTargetProcessor(TargetProcessor):
|
||||
stream = await asyncio.to_thread(css_manager.acquire, self._color_strip_source_id)
|
||||
self._color_strip_stream = stream
|
||||
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
|
||||
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:
|
||||
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(
|
||||
f"Acquired color strip stream for target {self._target_id} "
|
||||
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:
|
||||
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
|
||||
if css_manager and self._color_strip_source_id:
|
||||
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)
|
||||
except Exception as 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:
|
||||
"""Update target-specific timing settings."""
|
||||
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:
|
||||
self._standby_interval = settings["standby_interval"]
|
||||
if "state_check_interval" in settings:
|
||||
@@ -213,11 +227,12 @@ class WledTargetProcessor(TargetProcessor):
|
||||
old_id = self._color_strip_source_id
|
||||
try:
|
||||
new_stream = css_manager.acquire(color_strip_source_id)
|
||||
css_manager.remove_target_fps(old_id, self._target_id)
|
||||
css_manager.release(old_id)
|
||||
self._color_strip_stream = new_stream
|
||||
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
|
||||
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}")
|
||||
except Exception as 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:
|
||||
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)
|
||||
css_timing: dict = {}
|
||||
@@ -277,7 +292,7 @@ class WledTargetProcessor(TargetProcessor):
|
||||
|
||||
def get_metrics(self) -> dict:
|
||||
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
|
||||
if metrics.start_time and self._is_running:
|
||||
uptime_seconds = (datetime.utcnow() - metrics.start_time).total_seconds()
|
||||
@@ -406,15 +421,15 @@ class WledTargetProcessor(TargetProcessor):
|
||||
|
||||
logger.info(
|
||||
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:
|
||||
with high_resolution_timer():
|
||||
while self._is_running:
|
||||
loop_start = now = time.perf_counter()
|
||||
# Re-read target_fps each tick so hot-updates to the CSS source take effect
|
||||
target_fps = stream.target_fps if stream.target_fps > 0 else 30
|
||||
# Re-read target_fps each tick so hot-updates take effect immediately
|
||||
target_fps = self._target_fps if self._target_fps > 0 else 30
|
||||
frame_time = 1.0 / target_fps
|
||||
|
||||
# Re-fetch device info every ~30 iterations instead of every
|
||||
|
||||
@@ -18,7 +18,6 @@ class CSSEditorModal extends Modal {
|
||||
name: document.getElementById('css-editor-name').value,
|
||||
type,
|
||||
picture_source: document.getElementById('css-editor-picture-source').value,
|
||||
fps: document.getElementById('css-editor-fps').value,
|
||||
interpolation: document.getElementById('css-editor-interpolation').value,
|
||||
smoothing: document.getElementById('css-editor-smoothing').value,
|
||||
brightness: document.getElementById('css-editor-brightness').value,
|
||||
@@ -26,7 +25,7 @@ class CSSEditorModal extends Modal {
|
||||
gamma: document.getElementById('css-editor-gamma').value,
|
||||
color: document.getElementById('css-editor-color').value,
|
||||
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) : '[]',
|
||||
animation_enabled: document.getElementById('css-editor-animation-enabled').checked,
|
||||
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-color-cycle-section').style.display = type === 'color_cycle' ? '' : '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)
|
||||
const animSection = document.getElementById('css-editor-animation-section');
|
||||
@@ -205,7 +201,7 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
).join('');
|
||||
propsHtml = `
|
||||
<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>` : ''}
|
||||
`;
|
||||
} else if (isGradient) {
|
||||
@@ -227,6 +223,7 @@ export function createColorStripCard(source, pictureSourceMap) {
|
||||
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>` : ''}
|
||||
<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}
|
||||
`;
|
||||
} 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 ledCount = (source.led_count > 0) ? source.led_count : calLeds;
|
||||
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>
|
||||
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
|
||||
`;
|
||||
}
|
||||
|
||||
@@ -311,10 +307,6 @@ export async function showCSSEditor(cssId = null) {
|
||||
} else {
|
||||
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';
|
||||
|
||||
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-type').value = 'picture';
|
||||
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-smoothing').value = 0.3;
|
||||
document.getElementById('css-editor-smoothing-value').textContent = '0.30';
|
||||
@@ -432,7 +422,6 @@ export async function saveCSSEditor() {
|
||||
payload = {
|
||||
name,
|
||||
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,
|
||||
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
|
||||
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
|
||||
|
||||
@@ -72,6 +72,7 @@ class TargetEditorModal extends Modal {
|
||||
name: document.getElementById('target-editor-name').value,
|
||||
device: document.getElementById('target-editor-device').value,
|
||||
css: document.getElementById('target-editor-css').value,
|
||||
fps: document.getElementById('target-editor-fps').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;
|
||||
deviceSelect.value = target.device_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').textContent = target.standby_interval ?? 1.0;
|
||||
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
|
||||
document.getElementById('target-editor-id').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').textContent = '1.0';
|
||||
document.getElementById('target-editor-title').textContent = t('targets.add');
|
||||
@@ -203,10 +209,13 @@ export async function saveTargetEditor() {
|
||||
return;
|
||||
}
|
||||
|
||||
const fps = parseInt(document.getElementById('target-editor-fps').value) || 30;
|
||||
|
||||
const payload = {
|
||||
name,
|
||||
device_id: deviceId,
|
||||
color_strip_source_id: cssId,
|
||||
fps,
|
||||
standby_interval: standbyInterval,
|
||||
};
|
||||
|
||||
@@ -508,6 +517,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap) {
|
||||
</div>
|
||||
<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.fps')}">⚡ ${target.fps || 30} fps</span>
|
||||
<span class="stream-card-prop stream-card-prop-full" title="${t('targets.color_strip_source')}">🎞️ ${escapeHtml(cssName)}</span>
|
||||
</div>
|
||||
<div class="card-content">
|
||||
|
||||
@@ -102,6 +102,7 @@ class PictureTargetStore:
|
||||
target_type: str,
|
||||
device_id: str = "",
|
||||
color_strip_source_id: str = "",
|
||||
fps: int = 30,
|
||||
standby_interval: float = 1.0,
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL,
|
||||
key_colors_settings: Optional[KeyColorsSettings] = None,
|
||||
@@ -146,6 +147,7 @@ class PictureTargetStore:
|
||||
target_type="led",
|
||||
device_id=device_id,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
fps=fps,
|
||||
standby_interval=standby_interval,
|
||||
state_check_interval=state_check_interval,
|
||||
description=description,
|
||||
@@ -178,6 +180,7 @@ class PictureTargetStore:
|
||||
name: Optional[str] = None,
|
||||
device_id: Optional[str] = None,
|
||||
color_strip_source_id: Optional[str] = None,
|
||||
fps: Optional[int] = None,
|
||||
standby_interval: Optional[float] = None,
|
||||
state_check_interval: Optional[int] = None,
|
||||
key_colors_settings: Optional[KeyColorsSettings] = None,
|
||||
@@ -206,6 +209,7 @@ class PictureTargetStore:
|
||||
name=name,
|
||||
device_id=device_id,
|
||||
color_strip_source_id=color_strip_source_id,
|
||||
fps=fps,
|
||||
standby_interval=standby_interval,
|
||||
state_check_interval=state_check_interval,
|
||||
key_colors_settings=key_colors_settings,
|
||||
|
||||
@@ -13,13 +13,13 @@ DEFAULT_STATE_CHECK_INTERVAL = 30 # seconds
|
||||
class WledPictureTarget(PictureTarget):
|
||||
"""LED picture target — pairs an LED device with a ColorStripSource.
|
||||
|
||||
The ColorStripSource encapsulates everything needed to produce LED colors
|
||||
(calibration, color correction, smoothing, fps). The LED target itself only
|
||||
holds device-specific timing/keepalive settings.
|
||||
The ColorStripSource produces LED colors (calibration, color correction,
|
||||
smoothing). The target controls device-specific settings including send FPS.
|
||||
"""
|
||||
|
||||
device_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
|
||||
state_check_interval: int = DEFAULT_STATE_CHECK_INTERVAL
|
||||
|
||||
@@ -34,6 +34,7 @@ class WledPictureTarget(PictureTarget):
|
||||
target_id=self.id,
|
||||
device_id=self.device_id,
|
||||
color_strip_source_id=self.color_strip_source_id,
|
||||
fps=self.fps,
|
||||
standby_interval=self.standby_interval,
|
||||
state_check_interval=self.state_check_interval,
|
||||
)
|
||||
@@ -42,6 +43,7 @@ class WledPictureTarget(PictureTarget):
|
||||
"""Push changed fields to the processor manager."""
|
||||
if settings_changed:
|
||||
manager.update_target_settings(self.id, {
|
||||
"fps": self.fps,
|
||||
"standby_interval": self.standby_interval,
|
||||
"state_check_interval": self.state_check_interval,
|
||||
})
|
||||
@@ -51,7 +53,7 @@ class WledPictureTarget(PictureTarget):
|
||||
manager.update_target_device(self.id, self.device_id)
|
||||
|
||||
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:
|
||||
picture_source_id=None, settings=None,
|
||||
key_colors_settings=None, description=None) -> None:
|
||||
@@ -61,6 +63,8 @@ class WledPictureTarget(PictureTarget):
|
||||
self.device_id = device_id
|
||||
if color_strip_source_id is not None:
|
||||
self.color_strip_source_id = color_strip_source_id
|
||||
if fps is not None:
|
||||
self.fps = fps
|
||||
if standby_interval is not None:
|
||||
self.standby_interval = standby_interval
|
||||
if state_check_interval is not None:
|
||||
@@ -75,6 +79,7 @@ class WledPictureTarget(PictureTarget):
|
||||
d = super().to_dict()
|
||||
d["device_id"] = self.device_id
|
||||
d["color_strip_source_id"] = self.color_strip_source_id
|
||||
d["fps"] = self.fps
|
||||
d["standby_interval"] = self.standby_interval
|
||||
d["state_check_interval"] = self.state_check_interval
|
||||
return d
|
||||
@@ -88,6 +93,7 @@ class WledPictureTarget(PictureTarget):
|
||||
target_type="led",
|
||||
device_id=data.get("device_id", ""),
|
||||
color_strip_source_id=data.get("color_strip_source_id", ""),
|
||||
fps=data.get("fps", 30),
|
||||
standby_interval=data.get("standby_interval", 1.0),
|
||||
state_check_interval=data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
|
||||
description=data.get("description"),
|
||||
|
||||
@@ -39,19 +39,13 @@
|
||||
<select id="css-editor-picture-source"></select>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div id="css-editor-led-count-group" class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-fps">
|
||||
<span data-i18n="color_strip.fps">Target FPS:</span>
|
||||
<span id="css-editor-fps-value">30</span>
|
||||
</label>
|
||||
<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.fps.hint">Target frames per second for LED color updates (10-90)</small>
|
||||
<div class="slider-row">
|
||||
<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>
|
||||
<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 class="form-group">
|
||||
@@ -236,16 +230,6 @@
|
||||
</details>
|
||||
</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>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
@@ -32,6 +32,21 @@
|
||||
<select id="target-editor-css"></select>
|
||||
</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="label-row">
|
||||
<label for="target-editor-standby-interval">
|
||||
|
||||
Reference in New Issue
Block a user