From 136f6fd12042f942654a0bc841391a32a19e79ba Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 12 Feb 2026 01:55:00 +0300 Subject: [PATCH] Add Flip filter with bool option support and calibration UI polish - New FlipFilter with horizontal/vertical bool options (np.fliplr/flipud) - Add bool option type to filter system (base.py validate, app.js toggle UI) - Rearrange calibration preview: direction toggle above LED count - Normalize direction/offset control heights, brighten offset icon Co-Authored-By: Claude Opus 4.6 --- .../src/wled_controller/core/filters/base.py | 13 ++-- .../wled_controller/core/filters/builtin.py | 40 +++++++++++++ server/src/wled_controller/static/app.js | 33 +++++++---- server/src/wled_controller/static/index.html | 16 +++-- .../wled_controller/static/locales/en.json | 1 + .../wled_controller/static/locales/ru.json | 1 + server/src/wled_controller/static/style.css | 59 ++++++++++++++++++- 7 files changed, 136 insertions(+), 27 deletions(-) diff --git a/server/src/wled_controller/core/filters/base.py b/server/src/wled_controller/core/filters/base.py index dc6d18c..5316790 100644 --- a/server/src/wled_controller/core/filters/base.py +++ b/server/src/wled_controller/core/filters/base.py @@ -77,13 +77,16 @@ class PostprocessingFilter(ABC): val = float(raw) elif opt_def.option_type == "int": val = int(raw) + elif opt_def.option_type == "bool": + val = bool(raw) if not isinstance(raw, bool) else raw else: val = raw - # Clamp to range - 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: - val = opt_def.max_value + # Clamp to range (skip for bools) + if opt_def.option_type != "bool": + 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: + val = opt_def.max_value cleaned[opt_def.key] = val return cleaned diff --git a/server/src/wled_controller/core/filters/builtin.py b/server/src/wled_controller/core/filters/builtin.py index 1036262..d0c2cd1 100644 --- a/server/src/wled_controller/core/filters/builtin.py +++ b/server/src/wled_controller/core/filters/builtin.py @@ -280,3 +280,43 @@ class AutoCropFilter(PostprocessingFilter): result = image_pool.acquire(cropped_h, cropped_w, channels) np.copyto(result, image[top:bottom, left:right]) return result + + +@FilterRegistry.register +class FlipFilter(PostprocessingFilter): + """Flips the image horizontally and/or vertically.""" + + filter_id = "flip" + filter_name = "Flip" + + @classmethod + def get_options_schema(cls) -> List[FilterOptionDef]: + return [ + FilterOptionDef( + key="horizontal", + label="Horizontal", + option_type="bool", + default=False, + min_value=None, + max_value=None, + step=None, + ), + FilterOptionDef( + key="vertical", + label="Vertical", + option_type="bool", + default=False, + min_value=None, + max_value=None, + step=None, + ), + ] + + def process_image(self, image: np.ndarray, image_pool: ImagePool) -> Optional[np.ndarray]: + h = self.options.get("horizontal", False) + v = self.options.get("vertical", False) + if h: + image[:] = np.fliplr(image) + if v: + image[:] = np.flipud(image) + return None diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 37e29ae..8f71376 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -3808,15 +3808,26 @@ function renderModalFilterList() { for (const opt of filterDef.options_schema) { const currentVal = fi.options[opt.key] !== undefined ? fi.options[opt.key] : opt.default; const inputId = `filter-${index}-${opt.key}`; - html += `
- - -
`; + if (opt.type === 'bool') { + const checked = currentVal === true || currentVal === 'true'; + html += `
+ +
`; + } else { + html += `
+ + +
`; + } } } @@ -3873,7 +3884,9 @@ function updateFilterOption(filterIndex, optionKey, value) { const filterDef = _availableFilters.find(f => f.filter_id === fi.filter_id); if (filterDef) { const optDef = filterDef.options_schema.find(o => o.key === optionKey); - if (optDef && optDef.type === 'int') { + if (optDef && optDef.type === 'bool') { + fi.options[optionKey] = !!value; + } else if (optDef && optDef.type === 'int') { fi.options[optionKey] = parseInt(value); } else { fi.options[optionKey] = parseFloat(value); diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 3d1a342..4bc5f48 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -82,16 +82,14 @@
+
0 / 0
-
- - -
+
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index dd61673..51fedbb 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -257,6 +257,7 @@ "filters.downscaler": "Downscaler", "filters.pixelate": "Pixelate", "filters.auto_crop": "Auto Crop", + "filters.flip": "Flip", "postprocessing.description_label": "Description (optional):", "postprocessing.description_placeholder": "Describe this template...", "postprocessing.created": "Template created successfully", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 2053941..eab231b 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -257,6 +257,7 @@ "filters.downscaler": "Уменьшение", "filters.pixelate": "Пикселизация", "filters.auto_crop": "Авто Обрезка", + "filters.flip": "Отражение", "postprocessing.description_label": "Описание (необязательно):", "postprocessing.description_placeholder": "Опишите этот шаблон...", "postprocessing.created": "Шаблон успешно создан", diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 8f69e59..e27841c 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -1173,11 +1173,14 @@ input:-webkit-autofill:focus { display: flex; align-items: center; gap: 3px; - padding: 4px 8px; + height: 26px; + padding: 0 10px; background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 12px; font-size: 12px; + color: rgba(255, 255, 255, 0.9); + box-sizing: border-box; cursor: pointer; user-select: none; } @@ -1499,12 +1502,15 @@ input:-webkit-autofill:focus { display: flex; align-items: center; gap: 4px; - padding: 4px 10px; + height: 26px; + padding: 0 10px; background: rgba(255, 255, 255, 0.15); border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 12px; color: white; + font-family: inherit; font-size: 12px; + box-sizing: border-box; cursor: pointer; transition: background 0.2s; user-select: none; @@ -1515,7 +1521,7 @@ input:-webkit-autofill:focus { } .direction-toggle #direction-icon { - font-size: 16px; + font-size: 14px; } .preview-hint { @@ -2060,6 +2066,53 @@ input:-webkit-autofill:focus { width: 100%; } +.pp-filter-option-bool label { + justify-content: space-between; + gap: 8px; + align-items: center; + cursor: pointer; + padding: 4px 0; +} + +.pp-filter-option-bool input[type="checkbox"] { + appearance: none; + -webkit-appearance: none; + width: 34px; + min-width: 34px; + height: 18px; + background: var(--border-color); + border-radius: 9px; + position: relative; + cursor: pointer; + transition: background 0.2s; + order: 1; + margin: 0; +} + +.pp-filter-option-bool input[type="checkbox"]::after { + content: ''; + position: absolute; + top: 2px; + left: 2px; + width: 14px; + height: 14px; + background: white; + border-radius: 50%; + transition: transform 0.2s; +} + +.pp-filter-option-bool input[type="checkbox"]:checked { + background: var(--primary-color); +} + +.pp-filter-option-bool input[type="checkbox"]:checked::after { + transform: translateX(16px); +} + +.pp-filter-option-bool span { + order: 0; +} + .pp-add-filter-row { display: flex; gap: 8px;