Add composable filter templates, skip keepalive for serial devices

Filter Template meta-filter: reference existing PP templates inside others
for composable, DRY filter chains. Filters are recursively expanded at
pipeline build time with cycle detection. New `select` option type with
dynamic choices populated by the API.

Keepalive optimization: serial devices (Adalight, AmbiLED) don't need
keepalive — they hold last frame indefinitely. Check `standby_required`
capability at processor start, skip keepalive sends for serial targets,
and hide keepalive metrics in the UI. Rename "Standby Interval" to
"Keep Alive Interval" throughout the frontend.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-23 01:48:23 +03:00
parent a4083764fb
commit 9e555cef2e
12 changed files with 178 additions and 51 deletions

View File

@@ -19,6 +19,7 @@ import wled_controller.core.filters.auto_crop # noqa: F401
import wled_controller.core.filters.flip # noqa: F401
import wled_controller.core.filters.color_correction # noqa: F401
import wled_controller.core.filters.frame_interpolation # noqa: F401
import wled_controller.core.filters.filter_template # noqa: F401
__all__ = [
"FilterOptionDef",

View File

@@ -13,14 +13,15 @@ class FilterOptionDef:
key: str
label: str
option_type: str # "float" | "int"
option_type: str # "float" | "int" | "bool" | "select"
default: Any
min_value: Any
max_value: Any
step: Any
choices: Optional[List[Dict[str, str]]] = None # for "select": [{value, label}]
def to_dict(self) -> dict:
return {
d = {
"key": self.key,
"label": self.label,
"type": self.option_type,
@@ -29,6 +30,9 @@ class FilterOptionDef:
"max_value": self.max_value,
"step": self.step,
}
if self.choices is not None:
d["choices"] = self.choices
return d
class PostprocessingFilter(ABC):
@@ -80,10 +84,12 @@ class PostprocessingFilter(ABC):
val = int(raw)
elif opt_def.option_type == "bool":
val = bool(raw) if not isinstance(raw, bool) else raw
elif opt_def.option_type == "select":
val = str(raw) if raw is not None else opt_def.default
else:
val = raw
# Clamp to range (skip for bools)
if opt_def.option_type != "bool":
# Clamp to range (skip for bools and selects)
if opt_def.option_type not in ("bool", "select"):
if opt_def.min_value is not None and val < opt_def.min_value:
val = opt_def.min_value
if opt_def.max_value is not None and val > opt_def.max_value:

View File

@@ -0,0 +1,41 @@
"""Filter Template meta-filter — references another postprocessing template.
This filter exists in the registry for UI discovery only. It is never
instantiated at runtime: ``LiveStreamManager`` expands it into the
referenced template's filters when building the processing pipeline.
"""
from typing import List, Optional
import numpy as np
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
from wled_controller.core.filters.image_pool import ImagePool
from wled_controller.core.filters.registry import FilterRegistry
@FilterRegistry.register
class FilterTemplateFilter(PostprocessingFilter):
"""Include another filter template's chain at this position."""
filter_id = "filter_template"
filter_name = "Filter Template"
@classmethod
def get_options_schema(cls) -> List[FilterOptionDef]:
return [
FilterOptionDef(
key="template_id",
label="Template",
option_type="select",
default="",
min_value=None,
max_value=None,
step=None,
choices=[], # populated dynamically by GET /api/v1/filters
),
]
def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]:
# Never called — expanded at pipeline build time by LiveStreamManager.
return None

View File

@@ -226,18 +226,12 @@ class LiveStreamManager:
source_stream_id = config.source_stream_id
source_live = self.acquire(source_stream_id)
# Resolve postprocessing filters
# Resolve postprocessing filters (recursively expanding filter_template refs)
filters = []
if config.postprocessing_template_id and self._pp_template_store:
try:
pp = self._pp_template_store.get_template(config.postprocessing_template_id)
for fi in pp.filters:
try:
filters.append(
FilterRegistry.create_instance(fi.filter_id, fi.options)
)
except ValueError as e:
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
filters = self._resolve_filters(pp.filters)
except ValueError:
logger.warning(
f"PP template {config.postprocessing_template_id} not found, no filters applied"
@@ -245,6 +239,46 @@ class LiveStreamManager:
return ProcessedLiveStream(source_live, filters), source_stream_id
def _resolve_filters(self, filter_instances, _visited=None):
"""Recursively resolve filter instances, expanding filter_template refs.
Args:
filter_instances: List of FilterInstance configs.
_visited: Set of template IDs already in the expansion stack
(prevents circular references).
Returns:
List of instantiated PostprocessingFilter objects.
"""
if _visited is None:
_visited = set()
resolved = []
for fi in filter_instances:
if fi.filter_id == "filter_template":
template_id = fi.options.get("template_id", "")
if not template_id or not self._pp_template_store:
continue
if template_id in _visited:
logger.warning(
f"Circular filter template reference detected: {template_id}, skipping"
)
continue
try:
pp = self._pp_template_store.get_template(template_id)
_visited.add(template_id)
resolved.extend(self._resolve_filters(pp.filters, _visited))
_visited.discard(template_id)
except ValueError:
logger.warning(f"Referenced filter template '{template_id}' not found, skipping")
else:
try:
resolved.append(
FilterRegistry.create_instance(fi.filter_id, fi.options)
)
except ValueError as e:
logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}")
return resolved
def _create_static_image_live_stream(self, config) -> StaticImageLiveStream:
"""Create a StaticImageLiveStream from a StaticImagePictureSource config."""
image = self._load_static_image(config.image_source)

View File

@@ -10,7 +10,7 @@ from typing import Optional
import numpy as np
from wled_controller.core.devices.led_client import LEDClient, create_led_client
from wled_controller.core.devices.led_client import LEDClient, create_led_client, get_device_capabilities
from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.target_processor import (
DeviceInfo,
@@ -57,6 +57,7 @@ class WledTargetProcessor(TargetProcessor):
self._color_strip_stream = None
self._device_state_before: Optional[dict] = None
self._overlay_active = False
self._needs_keepalive = True # resolved at start from device capabilities
# Resolved stream metadata (set once stream is acquired)
self._resolved_display_index: Optional[int] = None
@@ -95,6 +96,7 @@ class WledTargetProcessor(TargetProcessor):
f"device ({device_info.led_count} LEDs)"
)
self._device_state_before = await self._led_client.snapshot_device_state()
self._needs_keepalive = "standby_required" in get_device_capabilities(device_info.device_type)
except Exception as e:
logger.error(f"Failed to connect to LED device for target {self._target_id}: {e}")
raise RuntimeError(f"Failed to connect to LED device: {e}")
@@ -290,6 +292,7 @@ class WledTargetProcessor(TargetProcessor):
"timing_total_ms": total_ms,
"display_index": self._resolved_display_index,
"overlay_active": self._overlay_active,
"needs_keepalive": self._needs_keepalive,
"last_update": metrics.last_update,
"errors": [metrics.last_error] if metrics.last_error else [],
}
@@ -467,8 +470,8 @@ class WledTargetProcessor(TargetProcessor):
continue
if colors is prev_colors:
# Same frame — send keepalive if interval elapsed
if prev_colors is not None and (loop_start - last_send_time) >= standby_interval:
# Same frame — send keepalive if interval elapsed (only for devices that need it)
if self._needs_keepalive and prev_colors is not None and (loop_start - last_send_time) >= standby_interval:
if not self._is_running or self._led_client is None:
break
kc = prev_colors