Codebase review: stability, performance, usability, and i18n fixes

Stability:
- Fix race condition: set _is_running before create_task in target processors
- Await probe task after cancel in wled_target_processor
- Replace raw fetch() with fetchWithAuth() across devices, kc-targets, pattern-templates
- Add try/catch to showTestTemplateModal in streams.js
- Wrap blocking I/O in asyncio.to_thread (picture_targets, system restore)
- Fix dashboardStopAll to filter only running targets with ok guard

Performance:
- Vectorize fire effect spark loop with numpy in effect_stream
- Vectorize FFT band binning with cumulative sum in analysis.py
- Rewrite pixel_processor with vectorized numpy (accept ndarray or list)
- Add httpx.AsyncClient connection pooling with lock in wled_provider
- Optimize _send_pixels_http to avoid np.hstack allocation in wled_client
- Mutate chart arrays in-place in dashboard, perf-charts, targets
- Merge dashboard 2-batch fetch into single Promise.all
- Hoist frame_time outside loop in mapped_stream

Usability:
- Fix health check interval load/save in device settings
- Swap confirm modal button classes (No=secondary, Yes=danger)
- Add aria-modal to audio/value source editors, fix close button aria-labels
- Add modal footer close button to settings modal
- Add dedicated calibration LED count validation error keys

i18n:
- Replace ~50 hardcoded English strings with t() calls across 12 JS files
- Add 50 new keys to en.json, ru.json, zh.json
- Localize inline toasts in index.html with window.t fallback
- Add data-i18n to command palette footer
- Add localization policy to CLAUDE.md

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-28 12:12:37 +03:00
parent c95c6e9a44
commit bd8d7a019f
31 changed files with 460 additions and 233 deletions
@@ -1,18 +1,27 @@
"""Pixel processing utilities for color correction and manipulation."""
from typing import List, Tuple
from typing import List, Tuple, Union
import numpy as np
from wled_controller.utils import get_logger
logger = get_logger(__name__)
ColorList = Union[List[Tuple[int, int, int]], np.ndarray]
def _as_array(colors: ColorList) -> np.ndarray:
"""Convert list-of-tuples to (N,3) uint8 array, or pass through if already ndarray."""
if isinstance(colors, np.ndarray):
return colors
return np.array(colors, dtype=np.uint8)
def smooth_colors(
current_colors: List[Tuple[int, int, int]],
previous_colors: List[Tuple[int, int, int]],
current_colors: ColorList,
previous_colors: ColorList,
smoothing_factor: float = 0.5,
) -> List[Tuple[int, int, int]]:
) -> np.ndarray:
"""Smooth color transitions between frames.
Args:
@@ -21,96 +30,71 @@ def smooth_colors(
smoothing_factor: Smoothing amount (0.0-1.0, where 0=no smoothing, 1=full smoothing)
Returns:
Smoothed colors
Smoothed colors as (N,3) uint8 ndarray
"""
if not current_colors or not previous_colors:
return current_colors
if not len(current_colors) or not len(previous_colors):
return _as_array(current_colors)
if len(current_colors) != len(previous_colors):
logger.warning(
f"Color count mismatch: current={len(current_colors)}, "
f"previous={len(previous_colors)}. Skipping smoothing."
)
return current_colors
return _as_array(current_colors)
if smoothing_factor <= 0:
return current_colors
return _as_array(current_colors)
if smoothing_factor >= 1:
return previous_colors
return _as_array(previous_colors)
# Convert to numpy arrays
current = np.array(current_colors, dtype=np.float32)
previous = np.array(previous_colors, dtype=np.float32)
# Blend between current and previous
current = np.asarray(current_colors, dtype=np.float32)
previous = np.asarray(previous_colors, dtype=np.float32)
smoothed = current * (1 - smoothing_factor) + previous * smoothing_factor
# Convert back to integers
smoothed = np.clip(smoothed, 0, 255).astype(np.uint8)
return [tuple(color) for color in smoothed]
return np.clip(smoothed, 0, 255).astype(np.uint8)
def adjust_brightness_global(
colors: List[Tuple[int, int, int]],
colors: ColorList,
target_brightness: int,
) -> List[Tuple[int, int, int]]:
) -> np.ndarray:
"""Adjust colors to achieve target global brightness.
Args:
colors: List of (R, G, B) tuples
colors: List of (R, G, B) tuples or (N,3) ndarray
target_brightness: Target brightness (0-255)
Returns:
Adjusted colors
Adjusted colors as (N,3) uint8 ndarray
"""
if not colors or target_brightness == 255:
return colors
arr = _as_array(colors)
if not len(arr) or target_brightness == 255:
return arr
# Calculate scaling factor
scale = target_brightness / 255.0
# Scale all colors
scaled = [
(
int(r * scale),
int(g * scale),
int(b * scale),
)
for r, g, b in colors
]
return scaled
return (arr.astype(np.float32) * scale).astype(np.uint8)
def limit_brightness(
colors: List[Tuple[int, int, int]],
colors: ColorList,
max_brightness: int = 255,
) -> List[Tuple[int, int, int]]:
) -> np.ndarray:
"""Limit maximum brightness of any color channel.
Args:
colors: List of (R, G, B) tuples
colors: List of (R, G, B) tuples or (N,3) ndarray
max_brightness: Maximum allowed brightness (0-255)
Returns:
Limited colors
Limited colors as (N,3) uint8 ndarray
"""
if not colors or max_brightness == 255:
return colors
arr = _as_array(colors)
if not len(arr) or max_brightness == 255:
return arr
limited = []
for r, g, b in colors:
# Find max channel value
max_val = max(r, g, b)
if max_val > max_brightness:
# Scale down proportionally
scale = max_brightness / max_val
r = int(r * scale)
g = int(g * scale)
b = int(b * scale)
limited.append((r, g, b))
return limited
arr_f = arr.astype(np.float32)
max_vals = np.max(arr_f, axis=1)
need_scale = max_vals > max_brightness
if need_scale.any():
scales = np.where(need_scale, max_brightness / np.maximum(max_vals, 1.0), 1.0)
arr_f *= scales[:, np.newaxis]
return arr_f.astype(np.uint8)