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:
@@ -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",
|
||||
|
||||
@@ -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:
|
||||
|
||||
41
server/src/wled_controller/core/filters/filter_template.py
Normal file
41
server/src/wled_controller/core/filters/filter_template.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user