Add animation effects + double-buffered FPS optimization

- 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 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 01:57:43 +03:00
parent 84f063eee9
commit 55a9662234
5 changed files with 220 additions and 25 deletions

View File

@@ -10,6 +10,7 @@ processing — border extraction, pixel mapping, color correction — runs only
even when multiple devices share the same source configuration. even when multiple devices share the same source configuration.
""" """
import colorsys
import math import math
import threading import threading
import time import time
@@ -537,8 +538,18 @@ class StaticColorStripStream(ColorStripStream):
logger.info("StaticColorStripStream params updated in-place") logger.info("StaticColorStripStream params updated in-place")
def _animate_loop(self) -> None: 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 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(): with high_resolution_timer():
while self._running: while self._running:
loop_start = time.perf_counter() loop_start = time.perf_counter()
@@ -548,13 +559,72 @@ class StaticColorStripStream(ColorStripStream):
atype = anim.get("type", "breathing") atype = anim.get("type", "breathing")
t = loop_start t = loop_start
n = self._led_count 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 colors = None
if atype == "breathing": if atype == "breathing":
factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5))
base = np.array(self._source_color, dtype=np.float32) r, g, b = self._source_color
pixel = np.clip(base * factor, 0, 255).astype(np.uint8) buf[:] = (min(255, int(r * factor)), min(255, int(g * factor)), min(255, int(b * factor)))
colors = np.tile(pixel, (n, 1)) 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: if colors is not None:
with self._colors_lock: with self._colors_lock:
@@ -652,8 +722,15 @@ class ColorCycleColorStripStream(ColorStripStream):
logger.info("ColorCycleColorStripStream params updated in-place") logger.info("ColorCycleColorStripStream params updated in-place")
def _animate_loop(self) -> None: 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 frame_time = 1.0 / 30
_pool_n = 0
_buf_a = _buf_b = None
_use_a = True
with high_resolution_timer(): with high_resolution_timer():
while self._running: while self._running:
loop_start = time.perf_counter() loop_start = time.perf_counter()
@@ -662,17 +739,28 @@ class ColorCycleColorStripStream(ColorStripStream):
n = self._led_count n = self._led_count
num = len(color_list) num = len(color_list)
if num >= 2: 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 # 0.05 factor → one full cycle every 20s at speed=1.0
cycle_pos = (speed * loop_start * 0.05) % 1.0 cycle_pos = (speed * loop_start * 0.05) % 1.0
seg = cycle_pos * num seg = cycle_pos * num
idx = int(seg) % num idx = int(seg) % num
t_interp = seg - int(seg) t_i = seg - int(seg)
c1 = np.array(color_list[idx], dtype=np.float32) c1 = color_list[idx]
c2 = np.array(color_list[(idx + 1) % num], dtype=np.float32) c2 = color_list[(idx + 1) % num]
pixel = np.clip(c1 * (1 - t_interp) + c2 * t_interp, 0, 255).astype(np.uint8) buf[:] = (
led_colors = np.tile(pixel, (n, 1)) 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: with self._colors_lock:
self._colors = led_colors self._colors = buf
elapsed = time.perf_counter() - loop_start elapsed = time.perf_counter() - loop_start
time.sleep(max(frame_time - elapsed, 0.001)) time.sleep(max(frame_time - elapsed, 0.001))
@@ -764,11 +852,20 @@ class GradientColorStripStream(ColorStripStream):
logger.info("GradientColorStripStream params updated in-place") logger.info("GradientColorStripStream params updated in-place")
def _animate_loop(self) -> None: 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 frame_time = 1.0 / 30
_cached_base: Optional[np.ndarray] = None _cached_base: Optional[np.ndarray] = None
_cached_n: int = 0 _cached_n: int = 0
_cached_stops: Optional[list] = None _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(): with high_resolution_timer():
while self._running: while self._running:
loop_start = time.perf_counter() loop_start = time.perf_counter()
@@ -788,25 +885,98 @@ class GradientColorStripStream(ColorStripStream):
_cached_stops = stops _cached_stops = stops
base = _cached_base 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": if atype == "breathing":
factor = 0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) int_f = max(0, min(256, int(0.5 * (1 + math.sin(2 * math.pi * speed * t * 0.5)) * 256)))
colors = np.clip(base.astype(np.float32) * factor, 0, 255).astype(np.uint8) 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": elif atype == "gradient_shift":
shift = int(speed * t * 10) % max(n, 1) 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": elif atype == "wave":
if n > 1: if n > 1:
i_arr = np.arange(n, dtype=np.float32) i_arr = np.arange(n, dtype=np.float32)
factor = 0.5 * (1 + np.sin( 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( int_factors = np.clip(factor * 256, 0, 256).astype(np.uint16)
base.astype(np.float32) * factor[:, None], 0, 255 np.copyto(_scratch_u16, base)
).astype(np.uint8) _scratch_u16 *= int_factors[:, None]
_scratch_u16 >>= 8
np.copyto(buf, _scratch_u16, casting='unsafe')
colors = buf
else: 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: if colors is not None:
with self._colors_lock: with self._colors_lock:

View File

@@ -370,6 +370,11 @@ class WledTargetProcessor(TargetProcessor):
last_send_time = 0.0 last_send_time = 0.0
prev_frame_time_stamp = time.perf_counter() prev_frame_time_stamp = time.perf_counter()
loop = asyncio.get_running_loop() 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( logger.info(
f"Processing loop started for target {self._target_id} " 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: while send_timestamps and send_timestamps[0] < now - 1.0:
send_timestamps.popleft() send_timestamps.popleft()
self._metrics.fps_current = len(send_timestamps) self._metrics.fps_current = len(send_timestamps)
await asyncio.sleep(frame_time) await asyncio.sleep(SKIP_REPOLL)
continue continue
prev_colors = colors prev_colors = colors

View File

@@ -57,13 +57,23 @@ export function onCSSTypeChange() {
if (type === 'static') { if (type === 'static') {
animSection.style.display = ''; animSection.style.display = '';
animTypeSelect.innerHTML = animTypeSelect.innerHTML =
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>`; `<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
`<option value="strobe">${t('color_strip.animation.type.strobe')}</option>` +
`<option value="sparkle">${t('color_strip.animation.type.sparkle')}</option>` +
`<option value="pulse">${t('color_strip.animation.type.pulse')}</option>` +
`<option value="candle">${t('color_strip.animation.type.candle')}</option>` +
`<option value="rainbow_fade">${t('color_strip.animation.type.rainbow_fade')}</option>`;
} else if (type === 'gradient') { } else if (type === 'gradient') {
animSection.style.display = ''; animSection.style.display = '';
animTypeSelect.innerHTML = animTypeSelect.innerHTML =
`<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` + `<option value="breathing">${t('color_strip.animation.type.breathing')}</option>` +
`<option value="gradient_shift">${t('color_strip.animation.type.gradient_shift')}</option>` + `<option value="gradient_shift">${t('color_strip.animation.type.gradient_shift')}</option>` +
`<option value="wave">${t('color_strip.animation.type.wave')}</option>`; `<option value="wave">${t('color_strip.animation.type.wave')}</option>` +
`<option value="strobe">${t('color_strip.animation.type.strobe')}</option>` +
`<option value="sparkle">${t('color_strip.animation.type.sparkle')}</option>` +
`<option value="pulse">${t('color_strip.animation.type.pulse')}</option>` +
`<option value="candle">${t('color_strip.animation.type.candle')}</option>` +
`<option value="rainbow_fade">${t('color_strip.animation.type.rainbow_fade')}</option>`;
} else { } else {
animSection.style.display = 'none'; animSection.style.display = 'none';
} }

View File

@@ -596,11 +596,16 @@
"color_strip.animation.enabled": "Enable Animation:", "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.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": "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.breathing": "Breathing",
"color_strip.animation.type.color_cycle": "Color Cycle", "color_strip.animation.type.color_cycle": "Color Cycle",
"color_strip.animation.type.gradient_shift": "Gradient Shift", "color_strip.animation.type.gradient_shift": "Gradient Shift",
"color_strip.animation.type.wave": "Wave", "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": "Speed:",
"color_strip.animation.speed.hint": "Animation speed multiplier. 1.0 ≈ one cycle per second for Breathing; higher values cycle faster.", "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:", "color_strip.color_cycle.colors": "Colors:",

View File

@@ -596,11 +596,16 @@
"color_strip.animation.enabled": "Включить анимацию:", "color_strip.animation.enabled": "Включить анимацию:",
"color_strip.animation.enabled.hint": "Включает процедурную анимацию. Светодиоды обновляются со скоростью 30 кадров в секунду по выбранному эффекту.", "color_strip.animation.enabled.hint": "Включает процедурную анимацию. Светодиоды обновляются со скоростью 30 кадров в секунду по выбранному эффекту.",
"color_strip.animation.type": "Эффект:", "color_strip.animation.type": "Эффект:",
"color_strip.animation.type.hint": "Эффект анимации. Дыхание работает для статичного цвета и градиента; сдвиг градиента и волна — только для градиентных источников.", "color_strip.animation.type.hint": "Эффект анимации. Дыхание, стробоскоп, искры, пульс, свеча и радужный перелив работают для статического цвета и градиента; сдвиг градиента и волна — только для градиентов.",
"color_strip.animation.type.breathing": "Дыхание", "color_strip.animation.type.breathing": "Дыхание",
"color_strip.animation.type.color_cycle": "Смена цвета", "color_strip.animation.type.color_cycle": "Смена цвета",
"color_strip.animation.type.gradient_shift": "Сдвиг градиента", "color_strip.animation.type.gradient_shift": "Сдвиг градиента",
"color_strip.animation.type.wave": "Волна", "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": "Скорость:",
"color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.", "color_strip.animation.speed.hint": "Множитель скорости анимации. 1.0 ≈ один цикл в секунду для дыхания; большие значения ускоряют анимацию.",
"color_strip.color_cycle.colors": "Цвета:", "color_strip.color_cycle.colors": "Цвета:",