From 55a9662234d8789cea24e7aa660df72ce5f2cf67 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Sat, 21 Feb 2026 01:57:43 +0300 Subject: [PATCH] Add animation effects + double-buffered FPS optimization MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add 5 new animation effects (strobe, sparkle, pulse, candle, rainbow fade) to both static and gradient color strip streams - Fix FPS drops (30→25) by using 5ms re-poll on frame skip instead of full frame_time, preventing synchronization misses between animation thread and processing loop - Double-buffer animation output arrays to eliminate per-frame numpy allocations and reduce GC pressure - Use uint16 integer math for gradient brightness scaling instead of float32 intermediates - Update animation type dropdowns and locale strings (en + ru) Co-Authored-By: Claude Opus 4.6 --- .../core/processing/color_strip_stream.py | 210 ++++++++++++++++-- .../core/processing/wled_target_processor.py | 7 +- .../static/js/features/color-strips.js | 14 +- .../wled_controller/static/locales/en.json | 7 +- .../wled_controller/static/locales/ru.json | 7 +- 5 files changed, 220 insertions(+), 25 deletions(-) diff --git a/server/src/wled_controller/core/processing/color_strip_stream.py b/server/src/wled_controller/core/processing/color_strip_stream.py index 53e58aa..9f61984 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -10,6 +10,7 @@ processing — border extraction, pixel mapping, color correction — runs only even when multiple devices share the same source configuration. """ +import colorsys import math import threading import time @@ -537,8 +538,18 @@ class StaticColorStripStream(ColorStripStream): logger.info("StaticColorStripStream params updated in-place") def _animate_loop(self) -> None: - """Background thread: compute animated colors at ~30 fps when animation is active.""" + """Background thread: compute animated colors at ~30 fps when animation is active. + + Uses double-buffered output arrays (buf_a / buf_b) to avoid per-frame + numpy allocations while preserving the identity check used by the + processing loop (``colors is prev_colors``). + """ frame_time = 1.0 / 30 + # Double-buffer pool — re-allocated only when LED count changes + _pool_n = 0 + _buf_a = _buf_b = None + _use_a = True + with high_resolution_timer(): while self._running: loop_start = time.perf_counter() @@ -548,13 +559,72 @@ class StaticColorStripStream(ColorStripStream): atype = anim.get("type", "breathing") t = loop_start n = self._led_count + + if n != _pool_n: + _pool_n = n + _buf_a = np.empty((n, 3), dtype=np.uint8) + _buf_b = np.empty((n, 3), dtype=np.uint8) + + buf = _buf_a if _use_a else _buf_b + _use_a = not _use_a colors = None if atype == "breathing": factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) - base = np.array(self._source_color, dtype=np.float32) - pixel = np.clip(base * factor, 0, 255).astype(np.uint8) - colors = np.tile(pixel, (n, 1)) + r, g, b = self._source_color + buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor))) + colors = buf + + elif atype == "strobe": + # Square wave: on for half the period, off for the other half. + # speed=1.0 → 2 flashes/sec (one full on/off cycle per 0.5s) + if math.sin(2 * math.pi * speed * t * 2.0) >= 0: + buf[:] = self._source_color + else: + buf[:] = 0 + colors = buf + + elif atype == "sparkle": + # Random LEDs flash white while the rest stay the base color + buf[:] = self._source_color + density = min(0.5, 0.1 * speed) + mask = np.random.random(n) < density + buf[mask] = (255, 255, 255) + colors = buf + + elif atype == "pulse": + # Sharp attack, slow exponential decay — heartbeat-like + # speed=1.0 → ~1 pulse per second + phase = (speed * t * 1.0) % 1.0 + if phase < 0.1: + factor = phase / 0.1 + else: + factor = math.exp(-5.0 * (phase - 0.1)) + r, g, b = self._source_color + buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor))) + colors = buf + + elif atype == "candle": + # Random brightness fluctuations simulating a candle flame + base_factor = 0.75 + flicker = 0.25 * math.sin(2 * math.pi * speed * t * 3.7) + flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3) + flicker += 0.10 * (np.random.random() - 0.5) + factor = max(0.2, min(1.0, base_factor + flicker)) + r, g, b = self._source_color + buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor))) + colors = buf + + elif atype == "rainbow_fade": + # Shift hue continuously from the base color + r, g, b = self._source_color + h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) + # speed=1.0 → one full hue rotation every ~10s + h_shift = (speed * t * 0.1) % 1.0 + new_h = (h + h_shift) % 1.0 + nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3)) + buf[:] = (int(nr * 255), int(ng * 255), int(nb * 255)) + colors = buf if colors is not None: with self._colors_lock: @@ -652,8 +722,15 @@ class ColorCycleColorStripStream(ColorStripStream): logger.info("ColorCycleColorStripStream params updated in-place") def _animate_loop(self) -> None: - """Background thread: interpolate between colors at ~30 fps.""" + """Background thread: interpolate between colors at ~30 fps. + + Uses double-buffered output arrays to avoid per-frame allocations. + """ frame_time = 1.0 / 30 + _pool_n = 0 + _buf_a = _buf_b = None + _use_a = True + with high_resolution_timer(): while self._running: loop_start = time.perf_counter() @@ -662,17 +739,28 @@ class ColorCycleColorStripStream(ColorStripStream): n = self._led_count num = len(color_list) if num >= 2: + if n != _pool_n: + _pool_n = n + _buf_a = np.empty((n, 3), dtype=np.uint8) + _buf_b = np.empty((n, 3), dtype=np.uint8) + + buf = _buf_a if _use_a else _buf_b + _use_a = not _use_a + # 0.05 factor → one full cycle every 20s at speed=1.0 cycle_pos = (speed * loop_start * 0.05) % 1.0 seg = cycle_pos * num idx = int(seg) % num - t_interp = seg - int(seg) - c1 = np.array(color_list[idx], dtype=np.float32) - c2 = np.array(color_list[(idx + 1) % num], dtype=np.float32) - pixel = np.clip(c1 * (1 - t_interp) + c2 * t_interp, 0, 255).astype(np.uint8) - led_colors = np.tile(pixel, (n, 1)) + t_i = seg - int(seg) + c1 = color_list[idx] + c2 = color_list[(idx + 1) % num] + buf[:] = ( + min(255, int(c1[0] + (c2[0] - c1[0]) * t_i)), + min(255, int(c1[1] + (c2[1] - c1[1]) * t_i)), + min(255, int(c1[2] + (c2[2] - c1[2]) * t_i)), + ) with self._colors_lock: - self._colors = led_colors + self._colors = buf elapsed = time.perf_counter() - loop_start time.sleep(max(frame_time - elapsed, 0.001)) @@ -764,11 +852,20 @@ class GradientColorStripStream(ColorStripStream): logger.info("GradientColorStripStream params updated in-place") def _animate_loop(self) -> None: - """Background thread: apply animation effects at ~30 fps when animation is active.""" + """Background thread: apply animation effects at ~30 fps when animation is active. + + Uses double-buffered output arrays plus a uint16 scratch buffer for + integer-math brightness scaling, avoiding per-frame numpy allocations. + """ frame_time = 1.0 / 30 _cached_base: Optional[np.ndarray] = None _cached_n: int = 0 _cached_stops: Optional[list] = None + # Double-buffer pool + uint16 scratch for brightness math + _pool_n = 0 + _buf_a = _buf_b = _scratch_u16 = None + _use_a = True + with high_resolution_timer(): while self._running: loop_start = time.perf_counter() @@ -788,25 +885,98 @@ class GradientColorStripStream(ColorStripStream): _cached_stops = stops base = _cached_base + # Re-allocate pool only when LED count changes + if n != _pool_n: + _pool_n = n + _buf_a = np.empty((n, 3), dtype=np.uint8) + _buf_b = np.empty((n, 3), dtype=np.uint8) + _scratch_u16 = np.empty((n, 3), dtype=np.uint16) + + buf = _buf_a if _use_a else _buf_b + _use_a = not _use_a + if atype == "breathing": - factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) - colors = np.clip(base.astype(np.float32) * factor, 0, 255).astype(np.uint8) + int_f = max(0, min(256, int(0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) * 256))) + np.copyto(_scratch_u16, base) + _scratch_u16 *= int_f + _scratch_u16 >>= 8 + np.copyto(buf, _scratch_u16, casting='unsafe') + colors = buf elif atype == "gradient_shift": shift = int(speed * t * 10) % max(n, 1) - colors = np.roll(base, shift, axis=0) + if shift > 0: + buf[:n - shift] = base[shift:] + buf[n - shift:] = base[:shift] + else: + np.copyto(buf, base) + colors = buf elif atype == "wave": if n > 1: i_arr = np.arange(n, dtype=np.float32) factor = 0.5 * (1 + np.sin( - 2 * math.pi * i_arr / n - 2 * math.pi * speed * t + 2 * math.pi * i_arr / n - 2 * math.pi * speed * t * 0.25 )) - colors = np.clip( - base.astype(np.float32) * factor[:, None], 0, 255 - ).astype(np.uint8) + int_factors = np.clip(factor * 256, 0, 256).astype(np.uint16) + np.copyto(_scratch_u16, base) + _scratch_u16 *= int_factors[:, None] + _scratch_u16 >>= 8 + np.copyto(buf, _scratch_u16, casting='unsafe') + colors = buf else: - colors = base + np.copyto(buf, base) + colors = buf + + elif atype == "strobe": + if math.sin(2 * math.pi * speed * t * 2.0) >= 0: + np.copyto(buf, base) + else: + buf[:] = 0 + colors = buf + + elif atype == "sparkle": + np.copyto(buf, base) + density = min(0.5, 0.1 * speed) + mask = np.random.random(n) < density + buf[mask] = (255, 255, 255) + colors = buf + + elif atype == "pulse": + phase = (speed * t * 1.0) % 1.0 + if phase < 0.1: + factor = phase / 0.1 + else: + factor = math.exp(-5.0 * (phase - 0.1)) + int_f = max(0, min(256, int(factor * 256))) + np.copyto(_scratch_u16, base) + _scratch_u16 *= int_f + _scratch_u16 >>= 8 + np.copyto(buf, _scratch_u16, casting='unsafe') + colors = buf + + elif atype == "candle": + base_factor = 0.75 + flicker = 0.25 * math.sin(2 * math.pi * speed * t * 3.7) + flicker += 0.15 * math.sin(2 * math.pi * speed * t * 7.3) + flicker += 0.10 * (np.random.random() - 0.5) + factor = max(0.2, min(1.0, base_factor + flicker)) + int_f = int(factor * 256) + np.copyto(_scratch_u16, base) + _scratch_u16 *= int_f + _scratch_u16 >>= 8 + np.copyto(buf, _scratch_u16, casting='unsafe') + colors = buf + + elif atype == "rainbow_fade": + h_shift = (speed * t * 0.1) % 1.0 + for i in range(n): + r, g, b = base[i] + h, s, v = colorsys.rgb_to_hsv(r / 255.0, g / 255.0, b / 255.0) + new_h = (h + h_shift) % 1.0 + nr, ng, nb = colorsys.hsv_to_rgb(new_h, max(s, 0.5), max(v, 0.3)) + buf[i] = (int(nr * 255), int(ng * 255), int(nb * 255)) + colors = buf if colors is not None: with self._colors_lock: diff --git a/server/src/wled_controller/core/processing/wled_target_processor.py b/server/src/wled_controller/core/processing/wled_target_processor.py index 9696f97..162468e 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -370,6 +370,11 @@ class WledTargetProcessor(TargetProcessor): last_send_time = 0.0 prev_frame_time_stamp = time.perf_counter() loop = asyncio.get_running_loop() + # Short re-poll interval when the animation thread hasn't produced a new + # frame yet. The animation thread and this loop both target the same FPS + # but are unsynchronised; without a short re-poll the loop can miss a + # frame and wait a full frame_time, periodically halving the send rate. + SKIP_REPOLL = 0.005 # 5 ms logger.info( f"Processing loop started for target {self._target_id} " @@ -419,7 +424,7 @@ class WledTargetProcessor(TargetProcessor): while send_timestamps and send_timestamps[0] < now - 1.0: send_timestamps.popleft() self._metrics.fps_current = len(send_timestamps) - await asyncio.sleep(frame_time) + await asyncio.sleep(SKIP_REPOLL) continue prev_colors = colors diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 81e1478..ec9b1c0 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -57,13 +57,23 @@ export function onCSSTypeChange() { if (type === 'static') { animSection.style.display = ''; animTypeSelect.innerHTML = - ``; + `` + + `` + + `` + + `` + + `` + + ``; } else if (type === 'gradient') { animSection.style.display = ''; animTypeSelect.innerHTML = `` + `` + - ``; + `` + + `` + + `` + + `` + + `` + + ``; } else { animSection.style.display = 'none'; } diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 3f95519..49b338a 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -596,11 +596,16 @@ "color_strip.animation.enabled": "Enable Animation:", "color_strip.animation.enabled.hint": "Enables procedural animation. The LEDs will update at 30 fps driven by the selected effect.", "color_strip.animation.type": "Effect:", - "color_strip.animation.type.hint": "The animation effect to apply. Breathing works for both static and gradient sources; Gradient Shift and Wave work for gradient sources only.", + "color_strip.animation.type.hint": "The animation effect to apply. Breathing, Strobe, Sparkle, Pulse, Candle, and Rainbow Fade work for both static and gradient sources; Gradient Shift and Wave are gradient-only.", "color_strip.animation.type.breathing": "Breathing", "color_strip.animation.type.color_cycle": "Color Cycle", "color_strip.animation.type.gradient_shift": "Gradient Shift", "color_strip.animation.type.wave": "Wave", + "color_strip.animation.type.strobe": "Strobe", + "color_strip.animation.type.sparkle": "Sparkle", + "color_strip.animation.type.pulse": "Pulse", + "color_strip.animation.type.candle": "Candle", + "color_strip.animation.type.rainbow_fade": "Rainbow Fade", "color_strip.animation.speed": "Speed:", "color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.", "color_strip.color_cycle.colors": "Colors:", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index bd9cd4a..e953312 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -596,11 +596,16 @@ "color_strip.animation.enabled": "Включить анимацию:", "color_strip.animation.enabled.hint": "Включает процедурную анимацию. Светодиоды обновляются со скоростью 30 кадров в секунду по выбранному эффекту.", "color_strip.animation.type": "Эффект:", - "color_strip.animation.type.hint": "Эффект анимации. Дыхание работает для статичного цвета и градиента; сдвиг градиента и волна — только для градиентных источников.", + "color_strip.animation.type.hint": "Эффект анимации. Дыхание, стробоскоп, искры, пульс, свеча и радужный перелив работают для статического цвета и градиента; сдвиг градиента и волна — только для градиентов.", "color_strip.animation.type.breathing": "Дыхание", "color_strip.animation.type.color_cycle": "Смена цвета", "color_strip.animation.type.gradient_shift": "Сдвиг градиента", "color_strip.animation.type.wave": "Волна", + "color_strip.animation.type.strobe": "Стробоскоп", + "color_strip.animation.type.sparkle": "Искры", + "color_strip.animation.type.pulse": "Пульс", + "color_strip.animation.type.candle": "Свеча", + "color_strip.animation.type.rainbow_fade": "Радужный перелив", "color_strip.animation.speed": "Скорость:", "color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.", "color_strip.color_cycle.colors": "Цвета:",