Fix filter_template expansion in test routes and select defaults
filter_template references were silently ignored in PP template test, picture source test, and KC target test routes — they created a no-op FilterTemplateFilter instead of expanding into the referenced template's filters. Centralized expansion logic into PostprocessingTemplateStore. resolve_filter_instances() and use it in all test routes + live stream manager. Also fixed empty template_id when adding filter_template filters: the select dropdown showed the first template visually but onchange never fired, saving "" instead. Now initializes with first choice's value and auto-corrects stale/empty values at render time. Other fixes: ScreenCapture dimensions now use actual image shape after filter processing; brightness source label emoji updates. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -417,11 +417,13 @@ async def test_picture_source(
|
|||||||
if pp_template_ids:
|
if pp_template_ids:
|
||||||
try:
|
try:
|
||||||
pp_template = pp_store.get_template(pp_template_ids[0])
|
pp_template = pp_store.get_template(pp_template_ids[0])
|
||||||
|
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
|
||||||
|
if flat_filters:
|
||||||
pool = ImagePool()
|
pool = ImagePool()
|
||||||
|
|
||||||
def apply_filters(img):
|
def apply_filters(img):
|
||||||
arr = np.array(img)
|
arr = np.array(img)
|
||||||
for fi in pp_template.filters:
|
for fi in flat_filters:
|
||||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
result = f.process_image(arr, pool)
|
result = f.process_image(arr, pool)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
|
|||||||
@@ -578,7 +578,8 @@ async def test_kc_target(
|
|||||||
except ValueError:
|
except ValueError:
|
||||||
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
|
logger.warning(f"KC test: PP template {pp_id} not found, skipping")
|
||||||
continue
|
continue
|
||||||
for fi in pp_template.filters:
|
flat_filters = pp_template_store.resolve_filter_instances(pp_template.filters)
|
||||||
|
for fi in flat_filters:
|
||||||
try:
|
try:
|
||||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
result = f.process_image(img_array, image_pool)
|
result = f.process_image(img_array, image_pool)
|
||||||
|
|||||||
@@ -283,13 +283,14 @@ async def test_pp_template(
|
|||||||
thumbnail = pil_image.copy()
|
thumbnail = pil_image.copy()
|
||||||
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS)
|
||||||
|
|
||||||
# Apply postprocessing filters
|
# Apply postprocessing filters (expand filter_template references)
|
||||||
if pp_template.filters:
|
flat_filters = pp_store.resolve_filter_instances(pp_template.filters)
|
||||||
|
if flat_filters:
|
||||||
pool = ImagePool()
|
pool = ImagePool()
|
||||||
|
|
||||||
def apply_filters(img):
|
def apply_filters(img):
|
||||||
arr = np.array(img)
|
arr = np.array(img)
|
||||||
for fi in pp_template.filters:
|
for fi in flat_filters:
|
||||||
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
f = FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
result = f.process_image(arr, pool)
|
result = f.process_image(arr, pool)
|
||||||
if result is not None:
|
if result is not None:
|
||||||
|
|||||||
@@ -7,6 +7,9 @@ import numpy as np
|
|||||||
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
from wled_controller.core.filters.base import FilterOptionDef, PostprocessingFilter
|
||||||
from wled_controller.core.filters.image_pool import ImagePool
|
from wled_controller.core.filters.image_pool import ImagePool
|
||||||
from wled_controller.core.filters.registry import FilterRegistry
|
from wled_controller.core.filters.registry import FilterRegistry
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
@FilterRegistry.register
|
@FilterRegistry.register
|
||||||
|
|||||||
@@ -256,8 +256,8 @@ class ProcessedLiveStream(LiveStream):
|
|||||||
if idle_image is not _idle_src_buf:
|
if idle_image is not _idle_src_buf:
|
||||||
processed = ScreenCapture(
|
processed = ScreenCapture(
|
||||||
image=idle_image,
|
image=idle_image,
|
||||||
width=cached_source_frame.width,
|
width=idle_image.shape[1],
|
||||||
height=cached_source_frame.height,
|
height=idle_image.shape[0],
|
||||||
display_index=cached_source_frame.display_index,
|
display_index=cached_source_frame.display_index,
|
||||||
)
|
)
|
||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
@@ -293,8 +293,8 @@ class ProcessedLiveStream(LiveStream):
|
|||||||
|
|
||||||
processed = ScreenCapture(
|
processed = ScreenCapture(
|
||||||
image=image,
|
image=image,
|
||||||
width=source_frame.width,
|
width=image.shape[1],
|
||||||
height=source_frame.height,
|
height=image.shape[0],
|
||||||
display_index=source_frame.display_index,
|
display_index=source_frame.display_index,
|
||||||
)
|
)
|
||||||
with self._frame_lock:
|
with self._frame_lock:
|
||||||
|
|||||||
@@ -239,38 +239,18 @@ class LiveStreamManager:
|
|||||||
|
|
||||||
return ProcessedLiveStream(source_live, filters), source_stream_id
|
return ProcessedLiveStream(source_live, filters), source_stream_id
|
||||||
|
|
||||||
def _resolve_filters(self, filter_instances, _visited=None):
|
def _resolve_filters(self, filter_instances):
|
||||||
"""Recursively resolve filter instances, expanding filter_template refs.
|
"""Resolve filter instances into instantiated PostprocessingFilter objects.
|
||||||
|
|
||||||
Args:
|
Expands filter_template references via the store, then creates instances.
|
||||||
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:
|
if self._pp_template_store:
|
||||||
_visited = set()
|
flat = self._pp_template_store.resolve_filter_instances(filter_instances)
|
||||||
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:
|
else:
|
||||||
|
flat = [fi for fi in filter_instances if fi.filter_id != "filter_template"]
|
||||||
|
|
||||||
|
resolved = []
|
||||||
|
for fi in flat:
|
||||||
try:
|
try:
|
||||||
resolved.append(
|
resolved.append(
|
||||||
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
FilterRegistry.create_instance(fi.filter_id, fi.options)
|
||||||
|
|||||||
@@ -1367,8 +1367,14 @@ export function renderModalFilterList() {
|
|||||||
const filteredChoices = (fi.filter_id === 'filter_template' && opt.key === 'template_id' && editingId)
|
const filteredChoices = (fi.filter_id === 'filter_template' && opt.key === 'template_id' && editingId)
|
||||||
? opt.choices.filter(c => c.value !== editingId)
|
? opt.choices.filter(c => c.value !== editingId)
|
||||||
: opt.choices;
|
: opt.choices;
|
||||||
|
// Auto-correct if current value doesn't match any choice
|
||||||
|
let selectVal = currentVal;
|
||||||
|
if (filteredChoices.length > 0 && !filteredChoices.some(c => c.value === selectVal)) {
|
||||||
|
selectVal = filteredChoices[0].value;
|
||||||
|
fi.options[opt.key] = selectVal;
|
||||||
|
}
|
||||||
const options = filteredChoices.map(c =>
|
const options = filteredChoices.map(c =>
|
||||||
`<option value="${escapeHtml(c.value)}"${c.value === currentVal ? ' selected' : ''}>${escapeHtml(c.label)}</option>`
|
`<option value="${escapeHtml(c.value)}"${c.value === selectVal ? ' selected' : ''}>${escapeHtml(c.label)}</option>`
|
||||||
).join('');
|
).join('');
|
||||||
html += `<div class="pp-filter-option">
|
html += `<div class="pp-filter-option">
|
||||||
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
|
<label for="${inputId}"><span>${escapeHtml(opt.label)}:</span></label>
|
||||||
@@ -1407,8 +1413,13 @@ export function addFilterFromSelect() {
|
|||||||
|
|
||||||
const options = {};
|
const options = {};
|
||||||
for (const opt of filterDef.options_schema) {
|
for (const opt of filterDef.options_schema) {
|
||||||
|
// For select options with empty default, use the first choice's value
|
||||||
|
if (opt.type === 'select' && !opt.default && Array.isArray(opt.choices) && opt.choices.length > 0) {
|
||||||
|
options[opt.key] = opt.choices[0].value;
|
||||||
|
} else {
|
||||||
options[opt.key] = opt.default;
|
options[opt.key] = opt.default;
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
|
_modalFilters.push({ filter_id: filterId, options, _expanded: true });
|
||||||
select.value = '';
|
select.value = '';
|
||||||
|
|||||||
@@ -431,7 +431,7 @@
|
|||||||
"kc.pattern_template": "Pattern Template:",
|
"kc.pattern_template": "Pattern Template:",
|
||||||
"kc.pattern_template.hint": "Select the rectangle pattern to use for color extraction",
|
"kc.pattern_template.hint": "Select the rectangle pattern to use for color extraction",
|
||||||
"kc.pattern_template.none": "-- Select a pattern template --",
|
"kc.pattern_template.none": "-- Select a pattern template --",
|
||||||
"kc.brightness_vs": "Brightness Source:",
|
"kc.brightness_vs": "🔢 Brightness Source:",
|
||||||
"kc.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (multiplied with the manual brightness slider)",
|
"kc.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (multiplied with the manual brightness slider)",
|
||||||
"kc.brightness_vs.none": "None (manual brightness only)",
|
"kc.brightness_vs.none": "None (manual brightness only)",
|
||||||
"kc.created": "Key colors target created successfully",
|
"kc.created": "Key colors target created successfully",
|
||||||
@@ -847,7 +847,7 @@
|
|||||||
"value_source.deleted": "Value source deleted",
|
"value_source.deleted": "Value source deleted",
|
||||||
"value_source.delete.confirm": "Are you sure you want to delete this value source?",
|
"value_source.delete.confirm": "Are you sure you want to delete this value source?",
|
||||||
"value_source.error.name_required": "Please enter a name",
|
"value_source.error.name_required": "Please enter a name",
|
||||||
"targets.brightness_vs": "Brightness Source:",
|
"targets.brightness_vs": "🔢 Brightness Source:",
|
||||||
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
|
"targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)",
|
||||||
"targets.brightness_vs.none": "None (device brightness)"
|
"targets.brightness_vs.none": "None (device brightness)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -431,7 +431,7 @@
|
|||||||
"kc.pattern_template": "Шаблон Паттерна:",
|
"kc.pattern_template": "Шаблон Паттерна:",
|
||||||
"kc.pattern_template.hint": "Выберите шаблон прямоугольников для извлечения цветов",
|
"kc.pattern_template.hint": "Выберите шаблон прямоугольников для извлечения цветов",
|
||||||
"kc.pattern_template.none": "-- Выберите шаблон паттерна --",
|
"kc.pattern_template.none": "-- Выберите шаблон паттерна --",
|
||||||
"kc.brightness_vs": "Источник Яркости:",
|
"kc.brightness_vs": "🔢 Источник Яркости:",
|
||||||
"kc.brightness_vs.hint": "Опциональный источник значений, динамически управляющий яркостью каждый кадр (умножается на ручной слайдер яркости)",
|
"kc.brightness_vs.hint": "Опциональный источник значений, динамически управляющий яркостью каждый кадр (умножается на ручной слайдер яркости)",
|
||||||
"kc.brightness_vs.none": "Нет (только ручная яркость)",
|
"kc.brightness_vs.none": "Нет (только ручная яркость)",
|
||||||
"kc.created": "Цель ключевых цветов успешно создана",
|
"kc.created": "Цель ключевых цветов успешно создана",
|
||||||
@@ -847,7 +847,7 @@
|
|||||||
"value_source.deleted": "Источник значений удалён",
|
"value_source.deleted": "Источник значений удалён",
|
||||||
"value_source.delete.confirm": "Удалить этот источник значений?",
|
"value_source.delete.confirm": "Удалить этот источник значений?",
|
||||||
"value_source.error.name_required": "Введите название",
|
"value_source.error.name_required": "Введите название",
|
||||||
"targets.brightness_vs": "Источник яркости:",
|
"targets.brightness_vs": "🔢 Источник яркости:",
|
||||||
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
|
"targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)",
|
||||||
"targets.brightness_vs.none": "Нет (яркость устройства)"
|
"targets.brightness_vs.none": "Нет (яркость устройства)"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -220,6 +220,30 @@ class PostprocessingTemplateStore:
|
|||||||
|
|
||||||
logger.info(f"Deleted postprocessing template: {template_id}")
|
logger.info(f"Deleted postprocessing template: {template_id}")
|
||||||
|
|
||||||
|
def resolve_filter_instances(self, filter_instances, _visited=None):
|
||||||
|
"""Recursively resolve filter instances, expanding filter_template references.
|
||||||
|
|
||||||
|
Returns a flat list of FilterInstance objects with no filter_template entries.
|
||||||
|
"""
|
||||||
|
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 template_id in _visited:
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
ref_template = self.get_template(template_id)
|
||||||
|
_visited.add(template_id)
|
||||||
|
resolved.extend(self.resolve_filter_instances(ref_template.filters, _visited))
|
||||||
|
_visited.discard(template_id)
|
||||||
|
except ValueError:
|
||||||
|
logger.warning(f"Referenced filter template '{template_id}' not found, skipping")
|
||||||
|
else:
|
||||||
|
resolved.append(fi)
|
||||||
|
return resolved
|
||||||
|
|
||||||
def get_sources_referencing(self, template_id: str, picture_source_store) -> List[str]:
|
def get_sources_referencing(self, template_id: str, picture_source_store) -> List[str]:
|
||||||
"""Return names of picture sources that reference this template."""
|
"""Return names of picture sources that reference this template."""
|
||||||
return [
|
return [
|
||||||
|
|||||||
Reference in New Issue
Block a user