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 5f0f9e2..a6c1b06 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -819,13 +819,16 @@ class GradientColorStripStream(ColorStripStream): def configure(self, device_led_count: int) -> None: """Size to device LED count when led_count was 0 (auto-size). - Only takes effect when the source was configured with led_count==0. - Silently ignored when an explicit led_count was set. + When multiple targets share this stream, uses the maximum LED count + so the gradient covers enough resolution for all consumers. Targets + with fewer LEDs get a properly resampled gradient via _fit_to_device. """ - if self._auto_size and device_led_count > 0 and device_led_count != self._led_count: - self._led_count = device_led_count - self._rebuild_colors() - logger.debug(f"GradientColorStripStream auto-sized to {device_led_count} LEDs") + if self._auto_size and device_led_count > 0: + new_count = max(self._led_count, device_led_count) + if new_count != self._led_count: + self._led_count = new_count + self._rebuild_colors() + logger.debug(f"GradientColorStripStream auto-sized to {new_count} LEDs") @property def target_fps(self) -> int: 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 e401c87..e72e8e6 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -382,19 +382,23 @@ class WledTargetProcessor(TargetProcessor): @staticmethod def _fit_to_device(colors: np.ndarray, device_led_count: int) -> np.ndarray: - """Truncate or repeat colors to match the target device LED count. + """Resample colors to match the target device LED count. - Shared streams may be sized to a different consumer's LED count. - This ensures each target only sends what its device actually needs. + Uses linear interpolation so gradients look correct regardless of + source/target LED count mismatch (shared streams may be sized to a + different consumer's LED count). """ n = len(colors) if n == device_led_count or device_led_count <= 0: return colors - if n > device_led_count: - return colors[:device_led_count] - # Tile to fill (rare — usually streams are >= device count) - reps = (device_led_count + n - 1) // n - return np.tile(colors, (reps, 1))[:device_led_count] + # Linear interpolation — preserves gradient appearance at any size + src_x = np.linspace(0, 1, n) + dst_x = np.linspace(0, 1, device_led_count) + result = np.column_stack([ + np.interp(dst_x, src_x, colors[:, ch]).astype(np.uint8) + for ch in range(colors.shape[1]) + ]) + return result async def _processing_loop(self) -> None: """Main processing loop — poll ColorStripStream → apply brightness → send.""" diff --git a/server/src/wled_controller/static/js/features/devices.js b/server/src/wled_controller/static/js/features/devices.js index 755a4c5..b524efe 100644 --- a/server/src/wled_controller/static/js/features/devices.js +++ b/server/src/wled_controller/static/js/features/devices.js @@ -309,11 +309,29 @@ export async function fetchDeviceBrightness(deviceId) { } } -// FPS hint helpers (shared with device-discovery) +// LED protocol timing constants +const LED_US_PER_BIT = 1.25; // SK6812/WS2812B bit time (μs) +const LED_BITS_PER_PIXEL = 32; // RGBW worst case (4 channels × 8 bits) +const LED_US_PER_PIXEL = LED_BITS_PER_PIXEL * LED_US_PER_BIT; // 40μs +const LED_RESET_US = 80; // reset/latch pulse (μs) +const US_PER_SECOND = 1_000_000; + +// Serial protocol constants +const SERIAL_BITS_PER_BYTE = 10; // 8N1: 1 start + 8 data + 1 stop +const SERIAL_RGB_BYTES_PER_LED = 3; +const ADALIGHT_HEADER_BYTES = 6; // 'Ada' + count_hi + count_lo + checksum +const AMBILED_HEADER_BYTES = 1; + +// FPS hint helpers (shared with device-discovery, targets) export function _computeMaxFps(baudRate, ledCount, deviceType) { - if (!baudRate || !ledCount || ledCount < 1) return null; - const overhead = deviceType === 'ambiled' ? 1 : 6; - const bitsPerFrame = (ledCount * 3 + overhead) * 10; + if (!ledCount || ledCount < 1) return null; + if (deviceType === 'wled') { + const frameUs = ledCount * LED_US_PER_PIXEL + LED_RESET_US; + return Math.floor(US_PER_SECOND / frameUs); + } + if (!baudRate) return null; + const overhead = deviceType === 'ambiled' ? AMBILED_HEADER_BYTES : ADALIGHT_HEADER_BYTES; + const bitsPerFrame = (ledCount * SERIAL_RGB_BYTES_PER_LED + overhead) * SERIAL_BITS_PER_BYTE; return Math.floor(baudRate / bitsPerFrame); } diff --git a/server/src/wled_controller/static/js/features/targets.js b/server/src/wled_controller/static/js/features/targets.js index c38d0bf..4d69216 100644 --- a/server/src/wled_controller/static/js/features/targets.js +++ b/server/src/wled_controller/static/js/features/targets.js @@ -12,7 +12,7 @@ import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js' import { t } from '../core/i18n.js'; import { showToast, showConfirm } from '../core/ui.js'; import { Modal } from '../core/modal.js'; -import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness } from './devices.js'; +import { createDeviceCard, attachDeviceListeners, fetchDeviceBrightness, _computeMaxFps } from './devices.js'; import { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js'; import { createColorStripCard } from './color-strips.js'; @@ -93,6 +93,23 @@ function _autoGenerateTargetName() { document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`; } +function _updateFpsRecommendation() { + const el = document.getElementById('target-editor-fps-rec'); + const deviceSelect = document.getElementById('target-editor-device'); + const device = _targetEditorDevices.find(d => d.id === deviceSelect.value); + if (!device || !device.led_count) { + el.style.display = 'none'; + return; + } + const fps = _computeMaxFps(device.baud_rate, device.led_count, device.device_type); + if (fps !== null) { + el.textContent = t('targets.fps.rec', { fps, leds: device.led_count }); + el.style.display = ''; + } else { + el.style.display = 'none'; + } +} + function _updateStandbyVisibility() { const deviceSelect = document.getElementById('target-editor-device'); const standbyGroup = document.getElementById('target-editor-standby-group'); @@ -167,12 +184,13 @@ export async function showTargetEditor(targetId = null) { // Auto-name generation _targetNameManuallyEdited = !!targetId; document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; }; - deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); }; + deviceSelect.onchange = () => { _updateStandbyVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); }; cssSelect.onchange = () => _autoGenerateTargetName(); if (!targetId) _autoGenerateTargetName(); // Show/hide standby interval based on selected device capabilities _updateStandbyVisibility(); + _updateFpsRecommendation(); targetEditorModal.snapshot(); targetEditorModal.open(); diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 314daea..3edd9f5 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -362,6 +362,7 @@ "targets.source.none": "-- No source assigned --", "targets.fps": "Target FPS:", "targets.fps.hint": "Target frames per second for capture and LED updates (1-90)", + "targets.fps.rec": "Hardware max ≈ {fps} fps ({leds} LEDs)", "targets.border_width": "Border Width (px):", "targets.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)", "targets.interpolation": "Interpolation Mode:", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 2ac9623..33622c8 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -362,6 +362,7 @@ "targets.source.none": "-- Источник не назначен --", "targets.fps": "Целевой FPS:", "targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)", + "targets.fps.rec": "Макс. аппаратный ≈ {fps} fps ({leds} LED)", "targets.border_width": "Ширина границы (px):", "targets.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)", "targets.interpolation": "Режим интерполяции:", diff --git a/server/src/wled_controller/templates/modals/target-editor.html b/server/src/wled_controller/templates/modals/target-editor.html index b98614d..e5bc206 100644 --- a/server/src/wled_controller/templates/modals/target-editor.html +++ b/server/src/wled_controller/templates/modals/target-editor.html @@ -45,6 +45,7 @@ fps +