Show max FPS hint in target editor, fix gradient sharing for multi-target
- Add dynamic "Hardware max ≈ N fps" recommendation below FPS slider, computed from LED count (WLED: protocol timing) or baud rate (serial). Reuses shared _computeMaxFps from devices.js with named constants. - Fix gradient looking different across targets sharing the same stream: configure() now uses max LED count across all consumers; _fit_to_device uses np.interp linear interpolation instead of truncate/tile. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -819,13 +819,16 @@ class GradientColorStripStream(ColorStripStream):
|
|||||||
def configure(self, device_led_count: int) -> None:
|
def configure(self, device_led_count: int) -> None:
|
||||||
"""Size to device LED count when led_count was 0 (auto-size).
|
"""Size to device LED count when led_count was 0 (auto-size).
|
||||||
|
|
||||||
Only takes effect when the source was configured with led_count==0.
|
When multiple targets share this stream, uses the maximum LED count
|
||||||
Silently ignored when an explicit led_count was set.
|
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:
|
if self._auto_size and device_led_count > 0:
|
||||||
self._led_count = device_led_count
|
new_count = max(self._led_count, device_led_count)
|
||||||
|
if new_count != self._led_count:
|
||||||
|
self._led_count = new_count
|
||||||
self._rebuild_colors()
|
self._rebuild_colors()
|
||||||
logger.debug(f"GradientColorStripStream auto-sized to {device_led_count} LEDs")
|
logger.debug(f"GradientColorStripStream auto-sized to {new_count} LEDs")
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def target_fps(self) -> int:
|
def target_fps(self) -> int:
|
||||||
|
|||||||
@@ -382,19 +382,23 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
def _fit_to_device(colors: np.ndarray, device_led_count: int) -> np.ndarray:
|
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.
|
Uses linear interpolation so gradients look correct regardless of
|
||||||
This ensures each target only sends what its device actually needs.
|
source/target LED count mismatch (shared streams may be sized to a
|
||||||
|
different consumer's LED count).
|
||||||
"""
|
"""
|
||||||
n = len(colors)
|
n = len(colors)
|
||||||
if n == device_led_count or device_led_count <= 0:
|
if n == device_led_count or device_led_count <= 0:
|
||||||
return colors
|
return colors
|
||||||
if n > device_led_count:
|
# Linear interpolation — preserves gradient appearance at any size
|
||||||
return colors[:device_led_count]
|
src_x = np.linspace(0, 1, n)
|
||||||
# Tile to fill (rare — usually streams are >= device count)
|
dst_x = np.linspace(0, 1, device_led_count)
|
||||||
reps = (device_led_count + n - 1) // n
|
result = np.column_stack([
|
||||||
return np.tile(colors, (reps, 1))[:device_led_count]
|
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:
|
async def _processing_loop(self) -> None:
|
||||||
"""Main processing loop — poll ColorStripStream → apply brightness → send."""
|
"""Main processing loop — poll ColorStripStream → apply brightness → send."""
|
||||||
|
|||||||
@@ -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) {
|
export function _computeMaxFps(baudRate, ledCount, deviceType) {
|
||||||
if (!baudRate || !ledCount || ledCount < 1) return null;
|
if (!ledCount || ledCount < 1) return null;
|
||||||
const overhead = deviceType === 'ambiled' ? 1 : 6;
|
if (deviceType === 'wled') {
|
||||||
const bitsPerFrame = (ledCount * 3 + overhead) * 10;
|
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);
|
return Math.floor(baudRate / bitsPerFrame);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'
|
|||||||
import { t } from '../core/i18n.js';
|
import { t } from '../core/i18n.js';
|
||||||
import { showToast, showConfirm } from '../core/ui.js';
|
import { showToast, showConfirm } from '../core/ui.js';
|
||||||
import { Modal } from '../core/modal.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 { createKCTargetCard, connectKCWebSocket, disconnectKCWebSocket } from './kc-targets.js';
|
||||||
import { createColorStripCard } from './color-strips.js';
|
import { createColorStripCard } from './color-strips.js';
|
||||||
|
|
||||||
@@ -93,6 +93,23 @@ function _autoGenerateTargetName() {
|
|||||||
document.getElementById('target-editor-name').value = `${deviceName} \u00b7 ${cssName}`;
|
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() {
|
function _updateStandbyVisibility() {
|
||||||
const deviceSelect = document.getElementById('target-editor-device');
|
const deviceSelect = document.getElementById('target-editor-device');
|
||||||
const standbyGroup = document.getElementById('target-editor-standby-group');
|
const standbyGroup = document.getElementById('target-editor-standby-group');
|
||||||
@@ -167,12 +184,13 @@ export async function showTargetEditor(targetId = null) {
|
|||||||
// Auto-name generation
|
// Auto-name generation
|
||||||
_targetNameManuallyEdited = !!targetId;
|
_targetNameManuallyEdited = !!targetId;
|
||||||
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
document.getElementById('target-editor-name').oninput = () => { _targetNameManuallyEdited = true; };
|
||||||
deviceSelect.onchange = () => { _updateStandbyVisibility(); _autoGenerateTargetName(); };
|
deviceSelect.onchange = () => { _updateStandbyVisibility(); _updateFpsRecommendation(); _autoGenerateTargetName(); };
|
||||||
cssSelect.onchange = () => _autoGenerateTargetName();
|
cssSelect.onchange = () => _autoGenerateTargetName();
|
||||||
if (!targetId) _autoGenerateTargetName();
|
if (!targetId) _autoGenerateTargetName();
|
||||||
|
|
||||||
// Show/hide standby interval based on selected device capabilities
|
// Show/hide standby interval based on selected device capabilities
|
||||||
_updateStandbyVisibility();
|
_updateStandbyVisibility();
|
||||||
|
_updateFpsRecommendation();
|
||||||
|
|
||||||
targetEditorModal.snapshot();
|
targetEditorModal.snapshot();
|
||||||
targetEditorModal.open();
|
targetEditorModal.open();
|
||||||
|
|||||||
@@ -362,6 +362,7 @@
|
|||||||
"targets.source.none": "-- No source assigned --",
|
"targets.source.none": "-- No source assigned --",
|
||||||
"targets.fps": "Target FPS:",
|
"targets.fps": "Target FPS:",
|
||||||
"targets.fps.hint": "Target frames per second for capture and LED updates (1-90)",
|
"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": "Border Width (px):",
|
||||||
"targets.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
"targets.border_width.hint": "How many pixels from the screen edge to sample for LED colors (1-100)",
|
||||||
"targets.interpolation": "Interpolation Mode:",
|
"targets.interpolation": "Interpolation Mode:",
|
||||||
|
|||||||
@@ -362,6 +362,7 @@
|
|||||||
"targets.source.none": "-- Источник не назначен --",
|
"targets.source.none": "-- Источник не назначен --",
|
||||||
"targets.fps": "Целевой FPS:",
|
"targets.fps": "Целевой FPS:",
|
||||||
"targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)",
|
"targets.fps.hint": "Целевая частота кадров для захвата и обновления LED (1-90)",
|
||||||
|
"targets.fps.rec": "Макс. аппаратный ≈ {fps} fps ({leds} LED)",
|
||||||
"targets.border_width": "Ширина границы (px):",
|
"targets.border_width": "Ширина границы (px):",
|
||||||
"targets.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
"targets.border_width.hint": "Сколько пикселей от края экрана выбирать для цвета LED (1-100)",
|
||||||
"targets.interpolation": "Режим интерполяции:",
|
"targets.interpolation": "Режим интерполяции:",
|
||||||
|
|||||||
@@ -45,6 +45,7 @@
|
|||||||
<input type="range" id="target-editor-fps" min="1" max="90" value="30" oninput="document.getElementById('target-editor-fps-value').textContent = this.value">
|
<input type="range" id="target-editor-fps" min="1" max="90" value="30" oninput="document.getElementById('target-editor-fps-value').textContent = this.value">
|
||||||
<span class="slider-value">fps</span>
|
<span class="slider-value">fps</span>
|
||||||
</div>
|
</div>
|
||||||
|
<small id="target-editor-fps-rec" class="input-hint" style="display:none"></small>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group" id="target-editor-standby-group">
|
<div class="form-group" id="target-editor-standby-group">
|
||||||
|
|||||||
Reference in New Issue
Block a user