Add per-layer LED preview for composite color strip sources
When a target uses a composite CSS source, the LED preview now shows individual layer strips below the blended composite result. Backend stores per-layer color snapshots and sends an extended binary wire format; frontend renders separate canvases with hover labels. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -42,6 +42,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
self._running = False
|
self._running = False
|
||||||
self._thread: Optional[threading.Thread] = None
|
self._thread: Optional[threading.Thread] = None
|
||||||
self._latest_colors: Optional[np.ndarray] = None
|
self._latest_colors: Optional[np.ndarray] = None
|
||||||
|
self._latest_layer_colors: Optional[List[np.ndarray]] = None
|
||||||
self._colors_lock = threading.Lock()
|
self._colors_lock = threading.Lock()
|
||||||
|
|
||||||
# layer_index -> (source_id, consumer_id, stream)
|
# layer_index -> (source_id, consumer_id, stream)
|
||||||
@@ -104,6 +105,11 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
return self._latest_colors
|
return self._latest_colors
|
||||||
|
|
||||||
|
def get_layer_colors(self) -> Optional[List[np.ndarray]]:
|
||||||
|
"""Return per-layer color snapshots (after resize/brightness, before blending)."""
|
||||||
|
with self._colors_lock:
|
||||||
|
return self._latest_layer_colors
|
||||||
|
|
||||||
def configure(self, device_led_count: int) -> None:
|
def configure(self, device_led_count: int) -> None:
|
||||||
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 and device_led_count != self._led_count:
|
||||||
self._led_count = device_led_count
|
self._led_count = device_led_count
|
||||||
@@ -301,6 +307,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
result_buf = self._result_a if self._use_a else self._result_b
|
result_buf = self._result_a if self._use_a else self._result_b
|
||||||
self._use_a = not self._use_a
|
self._use_a = not self._use_a
|
||||||
has_result = False
|
has_result = False
|
||||||
|
layer_snapshots: List[np.ndarray] = []
|
||||||
|
|
||||||
with self._sub_lock:
|
with self._sub_lock:
|
||||||
sub_snapshot = dict(self._sub_streams)
|
sub_snapshot = dict(self._sub_streams)
|
||||||
@@ -327,6 +334,9 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
if bri < 1.0:
|
if bri < 1.0:
|
||||||
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
|
colors = (colors.astype(np.uint16) * int(bri * 256) >> 8).astype(np.uint8)
|
||||||
|
|
||||||
|
# Snapshot layer colors before blending (copy — may alias shared buf)
|
||||||
|
layer_snapshots.append(colors.copy())
|
||||||
|
|
||||||
opacity = layer.get("opacity", 1.0)
|
opacity = layer.get("opacity", 1.0)
|
||||||
blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
|
blend_mode = layer.get("blend_mode", _BLEND_NORMAL)
|
||||||
alpha = int(opacity * 256)
|
alpha = int(opacity * 256)
|
||||||
@@ -348,6 +358,7 @@ class CompositeColorStripStream(ColorStripStream):
|
|||||||
if has_result:
|
if has_result:
|
||||||
with self._colors_lock:
|
with self._colors_lock:
|
||||||
self._latest_colors = result_buf
|
self._latest_colors = result_buf
|
||||||
|
self._latest_layer_colors = layer_snapshots if len(layer_snapshots) > 1 else None
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as e:
|
||||||
logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True)
|
logger.error(f"CompositeColorStripStream processing error: {e}", exc_info=True)
|
||||||
|
|||||||
@@ -77,6 +77,7 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
self._preview_clients: list = []
|
self._preview_clients: list = []
|
||||||
self._last_preview_colors: np.ndarray | None = None
|
self._last_preview_colors: np.ndarray | None = None
|
||||||
self._last_preview_brightness: int = 255
|
self._last_preview_brightness: int = 255
|
||||||
|
self._last_preview_data: bytes | None = None # cached full binary frame
|
||||||
|
|
||||||
# ----- Properties -----
|
# ----- Properties -----
|
||||||
|
|
||||||
@@ -481,9 +482,8 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
def add_led_preview_client(self, ws) -> None:
|
def add_led_preview_client(self, ws) -> None:
|
||||||
self._preview_clients.append(ws)
|
self._preview_clients.append(ws)
|
||||||
# Send last known frame immediately so late joiners see current state
|
# Send last known frame immediately so late joiners see current state
|
||||||
if self._last_preview_colors is not None:
|
if self._last_preview_data is not None:
|
||||||
data = bytes([self._last_preview_brightness]) + self._last_preview_colors.tobytes()
|
asyncio.ensure_future(self._send_preview_to(ws, self._last_preview_data))
|
||||||
asyncio.ensure_future(self._send_preview_to(ws, data))
|
|
||||||
|
|
||||||
@staticmethod
|
@staticmethod
|
||||||
async def _send_preview_to(ws, data: bytes) -> None:
|
async def _send_preview_to(ws, data: bytes) -> None:
|
||||||
@@ -499,14 +499,36 @@ class WledTargetProcessor(TargetProcessor):
|
|||||||
async def _broadcast_led_preview(self, colors: np.ndarray, brightness: int = 255) -> None:
|
async def _broadcast_led_preview(self, colors: np.ndarray, brightness: int = 255) -> None:
|
||||||
"""Broadcast LED colors as binary RGB bytes to preview WebSocket clients.
|
"""Broadcast LED colors as binary RGB bytes to preview WebSocket clients.
|
||||||
|
|
||||||
Wire format: [brightness_byte] [R G B R G B ...]
|
Standard wire format: [brightness_byte] [R G B R G B ...]
|
||||||
First byte is the effective brightness (0-255), rest is RGB pixel data.
|
Composite wire format: [brightness_byte] [0xFE] [layer_count] [led_count_hi] [led_count_lo]
|
||||||
|
[layer_0_rgb...] [layer_1_rgb...] ... [composite_rgb...]
|
||||||
"""
|
"""
|
||||||
if not self._preview_clients:
|
if not self._preview_clients:
|
||||||
return
|
return
|
||||||
|
|
||||||
|
# Check if source is composite with multiple layers
|
||||||
|
from wled_controller.core.processing.composite_stream import CompositeColorStripStream
|
||||||
|
stream = self._css_stream
|
||||||
|
layer_colors = None
|
||||||
|
if isinstance(stream, CompositeColorStripStream):
|
||||||
|
layer_colors = stream.get_layer_colors()
|
||||||
|
|
||||||
|
if layer_colors and len(layer_colors) > 1:
|
||||||
|
led_count = len(colors)
|
||||||
|
header = bytes([brightness, 0xFE, len(layer_colors),
|
||||||
|
(led_count >> 8) & 0xFF, led_count & 0xFF])
|
||||||
|
parts = [header]
|
||||||
|
for lc in layer_colors:
|
||||||
|
if len(lc) != led_count:
|
||||||
|
lc = self._fit_to_device(lc, led_count)
|
||||||
|
parts.append(lc.tobytes())
|
||||||
|
parts.append(colors.tobytes())
|
||||||
|
data = b''.join(parts)
|
||||||
|
else:
|
||||||
data = bytes([brightness]) + colors.tobytes()
|
data = bytes([brightness]) + colors.tobytes()
|
||||||
|
|
||||||
|
self._last_preview_data = data
|
||||||
|
|
||||||
async def _send_safe(ws):
|
async def _send_safe(ws):
|
||||||
try:
|
try:
|
||||||
await ws.send_bytes(data)
|
await ws.send_bytes(data)
|
||||||
|
|||||||
@@ -935,3 +935,47 @@ ul.section-tip li {
|
|||||||
.led-preview-zones:hover .led-preview-zone-label {
|
.led-preview-zones:hover .led-preview-zone-label {
|
||||||
opacity: 1;
|
opacity: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Per-layer LED preview (composite sources) */
|
||||||
|
.led-preview-layers {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-preview-layer {
|
||||||
|
position: relative;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-preview-layer-canvas {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
height: 14px;
|
||||||
|
border-radius: 2px;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
background: #111;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-preview-layer-composite .led-preview-layer-canvas {
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 3px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-preview-layer-label {
|
||||||
|
position: absolute;
|
||||||
|
left: 4px;
|
||||||
|
top: 50%;
|
||||||
|
transform: translateY(-50%);
|
||||||
|
font-size: 0.6rem;
|
||||||
|
font-family: var(--font-mono, monospace);
|
||||||
|
color: #fff;
|
||||||
|
text-shadow: 0 0 3px rgba(0,0,0,0.9), 0 0 6px rgba(0,0,0,0.6);
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.15s;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.led-preview-layers:hover .led-preview-layer-label {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1037,7 +1037,7 @@ export function createTargetCard(target, deviceMap, colorStripSourceMap, valueSo
|
|||||||
</div>
|
</div>
|
||||||
` : ''}
|
` : ''}
|
||||||
</div>
|
</div>
|
||||||
${_buildLedPreviewHtml(target.id, device, bvsId)}`,
|
${_buildLedPreviewHtml(target.id, device, bvsId, css, colorStripSourceMap)}`,
|
||||||
actions: `
|
actions: `
|
||||||
${isProcessing ? `
|
${isProcessing ? `
|
||||||
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
|
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('device.button.stop')}">
|
||||||
@@ -1211,7 +1211,7 @@ const _ledPreviewLastFrame = {};
|
|||||||
* For OpenRGB devices in "separate" zone mode with 2+ zones, renders
|
* For OpenRGB devices in "separate" zone mode with 2+ zones, renders
|
||||||
* one canvas per zone with labels. Otherwise, a single canvas.
|
* one canvas per zone with labels. Otherwise, a single canvas.
|
||||||
*/
|
*/
|
||||||
function _buildLedPreviewHtml(targetId, device, bvsId) {
|
function _buildLedPreviewHtml(targetId, device, bvsId, cssSource, colorStripSourceMap) {
|
||||||
const visible = ledPreviewWebSockets[targetId] ? '' : 'none';
|
const visible = ledPreviewWebSockets[targetId] ? '' : 'none';
|
||||||
const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
|
const bvsAttr = bvsId ? ' data-has-bvs="1"' : '';
|
||||||
|
|
||||||
@@ -1232,6 +1232,26 @@ function _buildLedPreviewHtml(targetId, device, bvsId) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check for composite source with per-layer preview
|
||||||
|
if (cssSource && cssSource.source_type === 'composite' && cssSource.layers && cssSource.layers.length > 1) {
|
||||||
|
const layerCanvases = cssSource.layers.filter(l => l.enabled !== false).map((l, i) => {
|
||||||
|
const layerSrc = colorStripSourceMap ? colorStripSourceMap[l.source_id] : null;
|
||||||
|
const layerName = layerSrc ? layerSrc.name : l.source_id;
|
||||||
|
return `<div class="led-preview-layer">` +
|
||||||
|
`<canvas class="led-preview-canvas led-preview-layer-canvas" data-layer-idx="${i}"></canvas>` +
|
||||||
|
`<span class="led-preview-layer-label">${escapeHtml(layerName)}</span>` +
|
||||||
|
`</div>`;
|
||||||
|
}).join('');
|
||||||
|
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel led-preview-layers" data-composite="1" style="display:${visible}">` +
|
||||||
|
`<div class="led-preview-layer led-preview-layer-composite">` +
|
||||||
|
`<canvas class="led-preview-canvas led-preview-layer-canvas" data-layer-idx="composite"></canvas>` +
|
||||||
|
`<span class="led-preview-layer-label">${escapeHtml(cssSource.name || 'Composite')}</span>` +
|
||||||
|
`</div>` +
|
||||||
|
layerCanvases +
|
||||||
|
`<span id="led-preview-brightness-${targetId}" class="led-preview-brightness" style="display:none"${bvsAttr}></span>` +
|
||||||
|
`</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Default: single canvas
|
// Default: single canvas
|
||||||
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel" style="display:${visible}">` +
|
return `<div id="led-preview-panel-${targetId}" class="led-preview-panel" style="display:${visible}">` +
|
||||||
`<canvas id="led-preview-canvas-${targetId}" class="led-preview-canvas"></canvas>` +
|
`<canvas id="led-preview-canvas-${targetId}" class="led-preview-canvas"></canvas>` +
|
||||||
@@ -1329,12 +1349,37 @@ function connectLedPreviewWS(targetId) {
|
|||||||
ws.onmessage = (event) => {
|
ws.onmessage = (event) => {
|
||||||
if (event.data instanceof ArrayBuffer) {
|
if (event.data instanceof ArrayBuffer) {
|
||||||
const raw = new Uint8Array(event.data);
|
const raw = new Uint8Array(event.data);
|
||||||
// Wire format: [brightness_byte] [R G B R G B ...]
|
|
||||||
const brightness = raw[0];
|
const brightness = raw[0];
|
||||||
|
|
||||||
|
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
||||||
|
|
||||||
|
// Composite wire format: [brightness] [0xFE] [layer_count] [led_count_hi] [led_count_lo] [layers...] [composite...]
|
||||||
|
if (raw.length > 4 && raw[1] === 0xFE && panel && panel.dataset.composite === '1') {
|
||||||
|
const layerCount = raw[2];
|
||||||
|
const ledCount = (raw[3] << 8) | raw[4];
|
||||||
|
const rgbSize = ledCount * 3;
|
||||||
|
let offset = 5;
|
||||||
|
|
||||||
|
// Render per-layer canvases (individual layers)
|
||||||
|
const layerCanvases = panel.querySelectorAll('.led-preview-layer-canvas[data-layer-idx]');
|
||||||
|
for (let i = 0; i < layerCount; i++) {
|
||||||
|
const layerRgb = raw.subarray(offset, offset + rgbSize);
|
||||||
|
offset += rgbSize;
|
||||||
|
// layer canvases: idx 0 = "composite", idx 1..N = individual layers
|
||||||
|
const canvas = layerCanvases[i + 1]; // +1 because composite canvas is first
|
||||||
|
if (canvas) _renderLedStrip(canvas, layerRgb);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Final composite result
|
||||||
|
const compositeRgb = raw.subarray(offset, offset + rgbSize);
|
||||||
|
_ledPreviewLastFrame[targetId] = compositeRgb;
|
||||||
|
const compositeCanvas = panel.querySelector('[data-layer-idx="composite"]');
|
||||||
|
if (compositeCanvas) _renderLedStrip(compositeCanvas, compositeRgb);
|
||||||
|
} else {
|
||||||
|
// Standard wire format: [brightness_byte] [R G B R G B ...]
|
||||||
const frame = raw.subarray(1);
|
const frame = raw.subarray(1);
|
||||||
_ledPreviewLastFrame[targetId] = frame;
|
_ledPreviewLastFrame[targetId] = frame;
|
||||||
|
|
||||||
const panel = document.getElementById(`led-preview-panel-${targetId}`);
|
|
||||||
if (panel) {
|
if (panel) {
|
||||||
if (panel.dataset.zoneMode === 'separate') {
|
if (panel.dataset.zoneMode === 'separate') {
|
||||||
_renderLedStripZones(panel, frame);
|
_renderLedStripZones(panel, frame);
|
||||||
@@ -1343,6 +1388,7 @@ function connectLedPreviewWS(targetId) {
|
|||||||
if (canvas) _renderLedStrip(canvas, frame);
|
if (canvas) _renderLedStrip(canvas, frame);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Show brightness label: always when a brightness source is set, otherwise only below 100%
|
// Show brightness label: always when a brightness source is set, otherwise only below 100%
|
||||||
const bLabel = document.getElementById(`led-preview-brightness-${targetId}`);
|
const bLabel = document.getElementById(`led-preview-brightness-${targetId}`);
|
||||||
|
|||||||
Reference in New Issue
Block a user