CSS: add StaticColorStripSource type with auto-sized LED count

Introduces a new 'static' source type that fills all device LEDs with a
single constant RGB color — no screen capture or processing required.

- StaticColorStripSource storage model (color + led_count=0 auto-size)
- StaticColorStripStream: no background thread, configure() sizes to device
  LED count at processor start; hot-updates preserve runtime size
- ColorStripStreamManager dispatches static sources (no LiveStream needed)
- WledTargetProcessor calls stream.configure(device_led_count) on start
- API schemas/routes: source_type Literal["picture","static"]; color field;
  overlay/calibration-test endpoints return 400 for static
- Frontend: type selector modal, color picker, type-aware card rendering
  (🎨 icon + color swatch), LED count field hidden for static type
- Locale keys: color_strip.type, color_strip.static_color (en + ru)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-20 17:49:48 +03:00
parent 0a23cb7043
commit 2a8e2daefc
12 changed files with 430 additions and 155 deletions

View File

@@ -26,7 +26,7 @@ from wled_controller.core.capture.calibration import (
) )
from wled_controller.core.capture.screen_capture import get_available_displays from wled_controller.core.capture.screen_capture import get_available_displays
from wled_controller.core.processing.processor_manager import ProcessorManager from wled_controller.core.processing.processor_manager import ProcessorManager
from wled_controller.storage.color_strip_source import PictureColorStripSource from wled_controller.storage.color_strip_source import PictureColorStripSource, StaticColorStripSource
from wled_controller.storage.color_strip_store import ColorStripStore from wled_controller.storage.color_strip_store import ColorStripStore
from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource from wled_controller.storage.picture_source import ProcessedPictureSource, ScreenCapturePictureSource
from wled_controller.storage.picture_source_store import PictureSourceStore from wled_controller.storage.picture_source_store import PictureSourceStore
@@ -57,6 +57,7 @@ def _css_to_response(source, overlay_active: bool = False) -> ColorStripSourceRe
interpolation_mode=getattr(source, "interpolation_mode", None), interpolation_mode=getattr(source, "interpolation_mode", None),
led_count=getattr(source, "led_count", 0), led_count=getattr(source, "led_count", 0),
calibration=calibration, calibration=calibration,
color=getattr(source, "color", None),
description=source.description, description=source.description,
overlay_active=overlay_active, overlay_active=overlay_active,
created_at=source.created_at, created_at=source.created_at,
@@ -117,6 +118,7 @@ async def create_color_strip_source(
interpolation_mode=data.interpolation_mode, interpolation_mode=data.interpolation_mode,
led_count=data.led_count, led_count=data.led_count,
calibration=calibration, calibration=calibration,
color=data.color,
description=data.description, description=data.description,
) )
return _css_to_response(source) return _css_to_response(source)
@@ -169,6 +171,7 @@ async def update_color_strip_source(
interpolation_mode=data.interpolation_mode, interpolation_mode=data.interpolation_mode,
led_count=data.led_count, led_count=data.led_count,
calibration=calibration, calibration=calibration,
color=data.color,
description=data.description, description=data.description,
) )
@@ -255,6 +258,11 @@ async def test_css_calibration(
if body.edges: if body.edges:
try: try:
source = store.get_source(source_id) source = store.get_source(source_id)
if isinstance(source, StaticColorStripSource):
raise HTTPException(
status_code=400,
detail="Calibration test is not applicable for static color strip sources",
)
if isinstance(source, PictureColorStripSource) and source.calibration: if isinstance(source, PictureColorStripSource) and source.calibration:
calibration = source.calibration calibration = source.calibration
except ValueError as e: except ValueError as e:
@@ -296,6 +304,8 @@ async def start_css_overlay(
"""Start screen overlay visualization for a color strip source.""" """Start screen overlay visualization for a color strip source."""
try: try:
source = store.get_source(source_id) source = store.get_source(source_id)
if isinstance(source, StaticColorStripSource):
raise HTTPException(status_code=400, detail="Overlay is not supported for static color strip sources")
if not isinstance(source, PictureColorStripSource): if not isinstance(source, PictureColorStripSource):
raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources") raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources")
if not source.calibration: if not source.calibration:

View File

@@ -12,7 +12,8 @@ class ColorStripSourceCreate(BaseModel):
"""Request to create a color strip source.""" """Request to create a color strip source."""
name: str = Field(description="Source name", min_length=1, max_length=100) name: str = Field(description="Source name", min_length=1, max_length=100)
source_type: Literal["picture"] = Field(default="picture", description="Source type") source_type: Literal["picture", "static"] = Field(default="picture", description="Source type")
# picture-type fields
picture_source_id: str = Field(default="", description="Picture source ID (for picture type)") picture_source_id: str = Field(default="", description="Picture source ID (for picture type)")
fps: int = Field(default=30, description="Target frames per second", ge=10, le=90) fps: int = Field(default=30, description="Target frames per second", ge=10, le=90)
brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) brightness: float = Field(default=1.0, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
@@ -20,8 +21,11 @@ class ColorStripSourceCreate(BaseModel):
gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0) gamma: float = Field(default=1.0, description="Gamma correction (1.0=none, <1=brighter, >1=darker mids)", ge=0.1, le=3.0)
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0) smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0=none, 1.0=full)", ge=0.0, le=1.0)
interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)") interpolation_mode: str = Field(default="average", description="LED color interpolation mode (average, median, dominant)")
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration)", ge=0)
calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)") calibration: Optional[Calibration] = Field(None, description="LED calibration (position and count per edge)")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
# shared
led_count: int = Field(default=0, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -29,6 +33,7 @@ class ColorStripSourceUpdate(BaseModel):
"""Request to update a color strip source.""" """Request to update a color strip source."""
name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100) name: Optional[str] = Field(None, description="Source name", min_length=1, max_length=100)
# picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: Optional[str] = Field(None, description="Picture source ID")
fps: Optional[int] = Field(None, description="Target FPS", ge=10, le=90) fps: Optional[int] = Field(None, description="Target FPS", ge=10, le=90)
brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0) brightness: Optional[float] = Field(None, description="Brightness multiplier (0.0-2.0)", ge=0.0, le=2.0)
@@ -36,8 +41,11 @@ class ColorStripSourceUpdate(BaseModel):
gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0) gamma: Optional[float] = Field(None, description="Gamma correction (0.1-3.0)", ge=0.1, le=3.0)
smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0) smoothing: Optional[float] = Field(None, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)") interpolation_mode: Optional[str] = Field(None, description="Interpolation mode (average, median, dominant)")
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration)", ge=0)
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Optional[Calibration] = Field(None, description="LED calibration")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B] (0-255 each, for static type)")
# shared
led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration / device)", ge=0)
description: Optional[str] = Field(None, description="Optional description", max_length=500) description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -47,6 +55,7 @@ class ColorStripSourceResponse(BaseModel):
id: str = Field(description="Source ID") id: str = Field(description="Source ID")
name: str = Field(description="Source name") name: str = Field(description="Source name")
source_type: str = Field(description="Source type") source_type: str = Field(description="Source type")
# picture-type fields
picture_source_id: Optional[str] = Field(None, description="Picture source ID") picture_source_id: Optional[str] = Field(None, description="Picture source ID")
fps: Optional[int] = Field(None, description="Target FPS") fps: Optional[int] = Field(None, description="Target FPS")
brightness: Optional[float] = Field(None, description="Brightness multiplier") brightness: Optional[float] = Field(None, description="Brightness multiplier")
@@ -54,8 +63,11 @@ class ColorStripSourceResponse(BaseModel):
gamma: Optional[float] = Field(None, description="Gamma correction") gamma: Optional[float] = Field(None, description="Gamma correction")
smoothing: Optional[float] = Field(None, description="Temporal smoothing") smoothing: Optional[float] = Field(None, description="Temporal smoothing")
interpolation_mode: Optional[str] = Field(None, description="Interpolation mode") interpolation_mode: Optional[str] = Field(None, description="Interpolation mode")
led_count: int = Field(0, description="Total LED count (0 = auto from calibration)")
calibration: Optional[Calibration] = Field(None, description="LED calibration") calibration: Optional[Calibration] = Field(None, description="LED calibration")
# static-type fields
color: Optional[List[int]] = Field(None, description="Static RGB color [R, G, B]")
# shared
led_count: int = Field(0, description="Total LED count (0 = auto from calibration / device)")
description: Optional[str] = Field(None, description="Description") description: Optional[str] = Field(None, description="Description")
overlay_active: bool = Field(False, description="Whether the screen overlay is currently active") overlay_active: bool = Field(False, description="Whether the screen overlay is currently active")
created_at: datetime = Field(description="Creation timestamp") created_at: datetime = Field(description="Creation timestamp")

View File

@@ -331,3 +331,72 @@ class PictureColorStripStream(ColorStripStream):
remaining = frame_time - elapsed remaining = frame_time - elapsed
if remaining > 0: if remaining > 0:
time.sleep(remaining) time.sleep(remaining)
class StaticColorStripStream(ColorStripStream):
"""Color strip stream that returns a constant single-color array.
No background thread needed — every call to get_latest_colors() returns
the same pre-built numpy array. Parameters can be hot-updated via
update_source().
"""
def __init__(self, source):
"""
Args:
source: StaticColorStripSource config
"""
self._update_from_source(source)
def _update_from_source(self, source) -> None:
color = source.color if isinstance(source.color, list) and len(source.color) == 3 else [255, 255, 255]
self._source_color = color # stored separately so configure() can rebuild
self._auto_size = not source.led_count # True when led_count == 0
led_count = source.led_count if source.led_count and source.led_count > 0 else 1
self._led_count = led_count
self._rebuild_colors()
def _rebuild_colors(self) -> None:
self._colors = np.tile(
np.array(self._source_color, dtype=np.uint8),
(self._led_count, 1),
)
def configure(self, device_led_count: int) -> None:
"""Set LED count from the target device (called by WledTargetProcessor on start).
Only takes effect when led_count was 0 (auto-size). Silently ignored
when an explicit led_count was configured on the source.
"""
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"StaticColorStripStream auto-sized to {device_led_count} LEDs")
@property
def target_fps(self) -> int:
return 30 # static output; any reasonable value is fine
@property
def led_count(self) -> int:
return self._led_count
def start(self) -> None:
logger.info(f"StaticColorStripStream started (leds={self._led_count})")
def stop(self) -> None:
logger.info("StaticColorStripStream stopped")
def get_latest_colors(self) -> Optional[np.ndarray]:
return self._colors
def update_source(self, source) -> None:
from wled_controller.storage.color_strip_source import StaticColorStripSource
if isinstance(source, StaticColorStripSource):
prev_led_count = self._led_count if self._auto_size else None
self._update_from_source(source)
# If we were auto-sized, preserve the runtime LED count across updates
if prev_led_count and self._auto_size:
self._led_count = prev_led_count
self._rebuild_colors()
logger.info("StaticColorStripStream params updated in-place")

View File

@@ -15,6 +15,7 @@ from typing import Dict, Optional
from wled_controller.core.processing.color_strip_stream import ( from wled_controller.core.processing.color_strip_stream import (
ColorStripStream, ColorStripStream,
PictureColorStripStream, PictureColorStripStream,
StaticColorStripStream,
) )
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -78,10 +79,21 @@ class ColorStripStreamManager:
) )
return entry.stream return entry.stream
from wled_controller.storage.color_strip_source import PictureColorStripSource from wled_controller.storage.color_strip_source import PictureColorStripSource, StaticColorStripSource
source = self._color_strip_store.get_source(css_id) source = self._color_strip_store.get_source(css_id)
if isinstance(source, StaticColorStripSource):
css_stream = StaticColorStripStream(source)
css_stream.start()
self._streams[css_id] = _ColorStripEntry(
stream=css_stream,
ref_count=1,
picture_source_id="", # no live stream to manage
)
logger.info(f"Created static color strip stream for source {css_id}")
return css_stream
if not isinstance(source, PictureColorStripSource): if not isinstance(source, PictureColorStripSource):
raise ValueError( raise ValueError(
f"Unsupported color strip source type '{source.source_type}' for {css_id}" f"Unsupported color strip source type '{source.source_type}' for {css_id}"
@@ -110,7 +122,7 @@ class ColorStripStreamManager:
picture_source_id=source.picture_source_id, picture_source_id=source.picture_source_id,
) )
logger.info(f"Created color strip stream for source {css_id}") logger.info(f"Created picture color strip stream for source {css_id}")
return css_stream return css_stream
def release(self, css_id: str) -> None: def release(self, css_id: str) -> None:
@@ -140,8 +152,9 @@ class ColorStripStreamManager:
del self._streams[css_id] del self._streams[css_id]
logger.info(f"Removed color strip stream for source {css_id}") logger.info(f"Removed color strip stream for source {css_id}")
# Release the underlying live stream # Release the underlying live stream (not needed for static sources)
self._live_stream_manager.release(picture_source_id) if picture_source_id:
self._live_stream_manager.release(picture_source_id)
def update_source(self, css_id: str, new_source) -> None: def update_source(self, css_id: str, new_source) -> None:
"""Hot-update processing params on a running stream. """Hot-update processing params on a running stream.

View File

@@ -114,6 +114,12 @@ class WledTargetProcessor(TargetProcessor):
self._color_strip_stream = stream self._color_strip_stream = stream
self._resolved_display_index = stream.display_index self._resolved_display_index = stream.display_index
self._resolved_target_fps = stream.target_fps self._resolved_target_fps = stream.target_fps
# For auto-sized static streams (led_count == 0), size to device LED count
from wled_controller.core.processing.color_strip_stream import StaticColorStripStream
if isinstance(stream, StaticColorStripStream) and device_info.led_count > 0:
stream.configure(device_info.led_count)
logger.info( logger.info(
f"Acquired color strip stream for target {self._target_id} " f"Acquired color strip stream for target {self._target_id} "
f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, " f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, "

View File

@@ -90,6 +90,7 @@ import {
// Layer 5: color-strip sources // Layer 5: color-strip sources
import { import {
showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip, showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip,
onCSSTypeChange,
} from './features/color-strips.js'; } from './features/color-strips.js';
// Layer 5: calibration // Layer 5: calibration
@@ -274,6 +275,7 @@ Object.assign(window, {
forceCSSEditorClose, forceCSSEditorClose,
saveCSSEditor, saveCSSEditor,
deleteColorStrip, deleteColorStrip,
onCSSTypeChange,
// calibration // calibration
showCalibration, showCalibration,

View File

@@ -13,8 +13,10 @@ class CSSEditorModal extends Modal {
} }
snapshotValues() { snapshotValues() {
const type = document.getElementById('css-editor-type').value;
return { return {
name: document.getElementById('css-editor-name').value, name: document.getElementById('css-editor-name').value,
type,
picture_source: document.getElementById('css-editor-picture-source').value, picture_source: document.getElementById('css-editor-picture-source').value,
fps: document.getElementById('css-editor-fps').value, fps: document.getElementById('css-editor-fps').value,
interpolation: document.getElementById('css-editor-interpolation').value, interpolation: document.getElementById('css-editor-interpolation').value,
@@ -22,39 +24,81 @@ class CSSEditorModal extends Modal {
brightness: document.getElementById('css-editor-brightness').value, brightness: document.getElementById('css-editor-brightness').value,
saturation: document.getElementById('css-editor-saturation').value, saturation: document.getElementById('css-editor-saturation').value,
gamma: document.getElementById('css-editor-gamma').value, gamma: document.getElementById('css-editor-gamma').value,
led_count: document.getElementById('css-editor-led-count').value, color: document.getElementById('css-editor-color').value,
led_count: type === 'static' ? '0' : document.getElementById('css-editor-led-count').value,
}; };
} }
} }
const cssEditorModal = new CSSEditorModal(); const cssEditorModal = new CSSEditorModal();
/* ── Type-switch helper ───────────────────────────────────────── */
export function onCSSTypeChange() {
const type = document.getElementById('css-editor-type').value;
document.getElementById('css-editor-picture-section').style.display = type === 'picture' ? '' : 'none';
document.getElementById('css-editor-static-section').style.display = type === 'static' ? '' : 'none';
// LED count is only meaningful for picture sources; static uses device LED count automatically
document.getElementById('css-editor-led-count-group').style.display = type === 'static' ? 'none' : '';
}
/** Convert an [R, G, B] array to a CSS hex color string like "#rrggbb". */
function rgbArrayToHex(rgb) {
if (!Array.isArray(rgb) || rgb.length !== 3) return '#ffffff';
return '#' + rgb.map(v => Math.max(0, Math.min(255, v)).toString(16).padStart(2, '0')).join('');
}
/** Convert a CSS hex string like "#rrggbb" to an [R, G, B] array. */
function hexToRgbArray(hex) {
const m = /^#?([0-9a-f]{2})([0-9a-f]{2})([0-9a-f]{2})$/i.exec(hex);
return m ? [parseInt(m[1], 16), parseInt(m[2], 16), parseInt(m[3], 16)] : [255, 255, 255];
}
/* ── Card ─────────────────────────────────────────────────────── */ /* ── Card ─────────────────────────────────────────────────────── */
export function createColorStripCard(source, pictureSourceMap) { export function createColorStripCard(source, pictureSourceMap) {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id]) const isStatic = source.source_type === 'static';
? pictureSourceMap[source.picture_source_id].name
: source.picture_source_id || '—'; let propsHtml;
const cal = source.calibration || {}; if (isStatic) {
const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0); const hexColor = rgbArrayToHex(source.color);
const ledCount = (source.led_count > 0) ? source.led_count : calLeds; propsHtml = `
<span class="stream-card-prop" title="${t('color_strip.static_color')}">
<span style="display:inline-block;width:14px;height:14px;background:${hexColor};border:1px solid #888;border-radius:2px;vertical-align:middle;margin-right:4px"></span>${hexColor.toUpperCase()}
</span>
${source.led_count ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${source.led_count}</span>` : ''}
`;
} else {
const srcName = (pictureSourceMap && pictureSourceMap[source.picture_source_id])
? pictureSourceMap[source.picture_source_id].name
: source.picture_source_id || '—';
const cal = source.calibration || {};
const calLeds = (cal.leds_top || 0) + (cal.leds_right || 0) + (cal.leds_bottom || 0) + (cal.leds_left || 0);
const ledCount = (source.led_count > 0) ? source.led_count : calLeds;
propsHtml = `
<span class="stream-card-prop" title="${t('color_strip.fps')}">⚡ ${source.fps || 30} fps</span>
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span>
`;
}
const icon = isStatic ? '🎨' : '🎞️';
const calibrationBtn = isStatic ? '' : `<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>`;
return ` return `
<div class="card" data-css-id="${source.id}"> <div class="card" data-css-id="${source.id}">
<button class="card-remove-btn" onclick="deleteColorStrip('${source.id}')" title="${t('common.delete')}">&#x2715;</button> <button class="card-remove-btn" onclick="deleteColorStrip('${source.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
🎞️ ${escapeHtml(source.name)} ${icon} ${escapeHtml(source.name)}
</div> </div>
</div> </div>
<div class="stream-card-props"> <div class="stream-card-props">
<span class="stream-card-prop" title="${t('color_strip.fps')}">⚡ ${source.fps || 30} fps</span> ${propsHtml}
${ledCount ? `<span class="stream-card-prop" title="${t('color_strip.leds')}">💡 ${ledCount}</span>` : ''}
<span class="stream-card-prop stream-card-prop-full" title="${t('color_strip.picture_source')}">📺 ${escapeHtml(srcName)}</span>
</div> </div>
<div class="card-actions"> <div class="card-actions">
<button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">✏️</button> <button class="btn btn-icon btn-secondary" onclick="showCSSEditor('${source.id}')" title="${t('common.edit')}">✏️</button>
<button class="btn btn-icon btn-secondary" onclick="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button> ${calibrationBtn}
</div> </div>
</div> </div>
`; `;
@@ -85,36 +129,46 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-id').value = css.id; document.getElementById('css-editor-id').value = css.id;
document.getElementById('css-editor-name').value = css.name; document.getElementById('css-editor-name').value = css.name;
sourceSelect.value = css.picture_source_id || '';
const fps = css.fps ?? 30; const sourceType = css.source_type || 'picture';
document.getElementById('css-editor-fps').value = fps; document.getElementById('css-editor-type').value = sourceType;
document.getElementById('css-editor-fps-value').textContent = fps; onCSSTypeChange();
document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average'; if (sourceType === 'static') {
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
} else {
sourceSelect.value = css.picture_source_id || '';
const smoothing = css.smoothing ?? 0.3; const fps = css.fps ?? 30;
document.getElementById('css-editor-smoothing').value = smoothing; document.getElementById('css-editor-fps').value = fps;
document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2); document.getElementById('css-editor-fps-value').textContent = fps;
const brightness = css.brightness ?? 1.0; document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average';
document.getElementById('css-editor-brightness').value = brightness;
document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2);
const saturation = css.saturation ?? 1.0; const smoothing = css.smoothing ?? 0.3;
document.getElementById('css-editor-saturation').value = saturation; document.getElementById('css-editor-smoothing').value = smoothing;
document.getElementById('css-editor-saturation-value').textContent = parseFloat(saturation).toFixed(2); document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2);
const gamma = css.gamma ?? 1.0; const brightness = css.brightness ?? 1.0;
document.getElementById('css-editor-gamma').value = gamma; document.getElementById('css-editor-brightness').value = brightness;
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2); document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2);
const saturation = css.saturation ?? 1.0;
document.getElementById('css-editor-saturation').value = saturation;
document.getElementById('css-editor-saturation-value').textContent = parseFloat(saturation).toFixed(2);
const gamma = css.gamma ?? 1.0;
document.getElementById('css-editor-gamma').value = gamma;
document.getElementById('css-editor-gamma-value').textContent = parseFloat(gamma).toFixed(2);
}
document.getElementById('css-editor-led-count').value = css.led_count ?? 0; document.getElementById('css-editor-led-count').value = css.led_count ?? 0;
document.getElementById('css-editor-title').textContent = t('color_strip.edit'); document.getElementById('css-editor-title').textContent = t('color_strip.edit');
} else { } else {
document.getElementById('css-editor-id').value = ''; document.getElementById('css-editor-id').value = '';
document.getElementById('css-editor-name').value = ''; document.getElementById('css-editor-name').value = '';
document.getElementById('css-editor-type').value = 'picture';
onCSSTypeChange();
document.getElementById('css-editor-fps').value = 30; document.getElementById('css-editor-fps').value = 30;
document.getElementById('css-editor-fps-value').textContent = '30'; document.getElementById('css-editor-fps-value').textContent = '30';
document.getElementById('css-editor-interpolation').value = 'average'; document.getElementById('css-editor-interpolation').value = 'average';
@@ -126,6 +180,7 @@ export async function showCSSEditor(cssId = null) {
document.getElementById('css-editor-saturation-value').textContent = '1.00'; document.getElementById('css-editor-saturation-value').textContent = '1.00';
document.getElementById('css-editor-gamma').value = 1.0; document.getElementById('css-editor-gamma').value = 1.0;
document.getElementById('css-editor-gamma-value').textContent = '1.00'; document.getElementById('css-editor-gamma-value').textContent = '1.00';
document.getElementById('css-editor-color').value = '#ffffff';
document.getElementById('css-editor-led-count').value = 0; document.getElementById('css-editor-led-count').value = 0;
document.getElementById('css-editor-title').textContent = t('color_strip.add'); document.getElementById('css-editor-title').textContent = t('color_strip.add');
} }
@@ -150,23 +205,38 @@ export function isCSSEditorDirty() { return cssEditorModal.isDirty(); }
export async function saveCSSEditor() { export async function saveCSSEditor() {
const cssId = document.getElementById('css-editor-id').value; const cssId = document.getElementById('css-editor-id').value;
const name = document.getElementById('css-editor-name').value.trim(); const name = document.getElementById('css-editor-name').value.trim();
const sourceType = document.getElementById('css-editor-type').value;
if (!name) { if (!name) {
cssEditorModal.showError(t('color_strip.error.name_required')); cssEditorModal.showError(t('color_strip.error.name_required'));
return; return;
} }
const payload = { let payload;
name, if (sourceType === 'static') {
picture_source_id: document.getElementById('css-editor-picture-source').value, payload = {
fps: parseInt(document.getElementById('css-editor-fps').value) || 30, name,
interpolation_mode: document.getElementById('css-editor-interpolation').value, color: hexToRgbArray(document.getElementById('css-editor-color').value),
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value), };
brightness: parseFloat(document.getElementById('css-editor-brightness').value), if (!cssId) {
saturation: parseFloat(document.getElementById('css-editor-saturation').value), payload.source_type = 'static';
gamma: parseFloat(document.getElementById('css-editor-gamma').value), }
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0, } else {
}; payload = {
name,
picture_source_id: document.getElementById('css-editor-picture-source').value,
fps: parseInt(document.getElementById('css-editor-fps').value) || 30,
interpolation_mode: document.getElementById('css-editor-interpolation').value,
smoothing: parseFloat(document.getElementById('css-editor-smoothing').value),
brightness: parseFloat(document.getElementById('css-editor-brightness').value),
saturation: parseFloat(document.getElementById('css-editor-saturation').value),
gamma: parseFloat(document.getElementById('css-editor-gamma').value),
led_count: parseInt(document.getElementById('css-editor-led-count').value) || 0,
};
if (!cssId) {
payload.source_type = 'picture';
}
}
try { try {
let response; let response;
@@ -176,7 +246,6 @@ export async function saveCSSEditor() {
body: JSON.stringify(payload), body: JSON.stringify(payload),
}); });
} else { } else {
payload.source_type = 'picture';
response = await fetchWithAuth('/color-strip-sources', { response = await fetchWithAuth('/color-strip-sources', {
method: 'POST', method: 'POST',
body: JSON.stringify(payload), body: JSON.stringify(payload),

View File

@@ -563,11 +563,17 @@
"color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles", "color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles",
"color_strip.leds": "LED count", "color_strip.leds": "LED count",
"color_strip.led_count": "LED Count:", "color_strip.led_count": "LED Count:",
"color_strip.led_count.hint": "Total number of LEDs on the physical strip. Set to 0 to use the sum from calibration. Useful when the strip has LEDs behind the TV that are not mapped to screen edges — those LEDs will be sent black.", "color_strip.led_count.hint": "Total number of LEDs on the physical strip. For screen sources: 0 = auto from calibration (extra LEDs not mapped to edges will be black). For static color: set to match your device LED count.",
"color_strip.created": "Color strip source created", "color_strip.created": "Color strip source created",
"color_strip.updated": "Color strip source updated", "color_strip.updated": "Color strip source updated",
"color_strip.deleted": "Color strip source deleted", "color_strip.deleted": "Color strip source deleted",
"color_strip.delete.confirm": "Are you sure you want to delete this color strip source?", "color_strip.delete.confirm": "Are you sure you want to delete this color strip source?",
"color_strip.delete.referenced": "Cannot delete: this source is in use by a target", "color_strip.delete.referenced": "Cannot delete: this source is in use by a target",
"color_strip.error.name_required": "Please enter a name" "color_strip.error.name_required": "Please enter a name",
"color_strip.type": "Type:",
"color_strip.type.hint": "Picture Source derives LED colors from a screen capture. Static Color fills all LEDs with a single constant color.",
"color_strip.type.picture": "Picture Source",
"color_strip.type.static": "Static Color",
"color_strip.static_color": "Color:",
"color_strip.static_color.hint": "The solid color that will be sent to all LEDs on the strip."
} }

View File

@@ -563,11 +563,17 @@
"color_strip.test_device.hint": "Выберите устройство для отправки тестовых пикселей при нажатии на рамку", "color_strip.test_device.hint": "Выберите устройство для отправки тестовых пикселей при нажатии на рамку",
"color_strip.leds": "Количество светодиодов", "color_strip.leds": "Количество светодиодов",
"color_strip.led_count": "Количество LED:", "color_strip.led_count": "Количество LED:",
"color_strip.led_count.hint": "Общее число светодиодов на физической полосе. 0 = взять из калибровки. Укажите явно, если на полосе есть светодиоды за телевизором, не привязанные к краям экрана — им будет отправлен чёрный цвет.", "color_strip.led_count.hint": "Общее число светодиодов на физической полосе. Для источников экрана: 0 = автоматически из калибровки (светодиоды за ТВ будут чёрными). Для статического цвета: укажите точное количество светодиодов устройства.",
"color_strip.created": "Источник цветовой полосы создан", "color_strip.created": "Источник цветовой полосы создан",
"color_strip.updated": "Источник цветовой полосы обновлён", "color_strip.updated": "Источник цветовой полосы обновлён",
"color_strip.deleted": "Источник цветовой полосы удалён", "color_strip.deleted": "Источник цветовой полосы удалён",
"color_strip.delete.confirm": "Удалить этот источник цветовой полосы?", "color_strip.delete.confirm": "Удалить этот источник цветовой полосы?",
"color_strip.delete.referenced": "Невозможно удалить: источник используется в цели", "color_strip.delete.referenced": "Невозможно удалить: источник используется в цели",
"color_strip.error.name_required": "Введите название" "color_strip.error.name_required": "Введите название",
"color_strip.type": "Тип:",
"color_strip.type.hint": "Источник изображения получает цвета светодиодов из захвата экрана. Статический цвет заполняет все светодиоды одним постоянным цветом.",
"color_strip.type.picture": "Источник изображения",
"color_strip.type.static": "Статический цвет",
"color_strip.static_color": "Цвет:",
"color_strip.static_color.hint": "Статический цвет, который будет отправлен на все светодиоды полосы."
} }

View File

@@ -6,9 +6,9 @@ calibration, color correction, smoothing, and FPS.
Current types: Current types:
PictureColorStripSource — derives LED colors from a PictureSource (screen capture) PictureColorStripSource — derives LED colors from a PictureSource (screen capture)
StaticColorStripSource — constant solid color fills all LEDs
Future types (not yet implemented): Future types (not yet implemented):
StaticColorStripSource — constant solid colors
GradientColorStripSource — animated gradient GradientColorStripSource — animated gradient
""" """
@@ -53,6 +53,7 @@ class ColorStripSource:
"interpolation_mode": None, "interpolation_mode": None,
"calibration": None, "calibration": None,
"led_count": None, "led_count": None,
"color": None,
} }
@staticmethod @staticmethod
@@ -85,7 +86,20 @@ class ColorStripSource:
else CalibrationConfig(layout="clockwise", start_position="bottom_left") else CalibrationConfig(layout="clockwise", start_position="bottom_left")
) )
# Only "picture" type for now; extend with elif branches for future types if source_type == "static":
raw_color = data.get("color")
color = (
raw_color if isinstance(raw_color, list) and len(raw_color) == 3
else [255, 255, 255]
)
return StaticColorStripSource(
id=sid, name=name, source_type="static",
created_at=created_at, updated_at=updated_at, description=description,
color=color,
led_count=data.get("led_count") or 0,
)
# Default: "picture" type
return PictureColorStripSource( return PictureColorStripSource(
id=sid, name=name, source_type=source_type, id=sid, name=name, source_type=source_type,
created_at=created_at, updated_at=updated_at, description=description, created_at=created_at, updated_at=updated_at, description=description,
@@ -133,3 +147,22 @@ class PictureColorStripSource(ColorStripSource):
d["calibration"] = calibration_to_dict(self.calibration) d["calibration"] = calibration_to_dict(self.calibration)
d["led_count"] = self.led_count d["led_count"] = self.led_count
return d return d
@dataclass
class StaticColorStripSource(ColorStripSource):
"""Color strip source that fills all LEDs with a single static color.
No capture or processing — the entire LED strip is set to one constant
RGB color. Useful for solid-color accents or as a placeholder while
a PictureColorStripSource is being configured.
"""
color: list = field(default_factory=lambda: [255, 255, 255]) # [R, G, B]
led_count: int = 0 # 0 = use device LED count
def to_dict(self) -> dict:
d = super().to_dict()
d["color"] = list(self.color)
d["led_count"] = self.led_count
return d

View File

@@ -10,6 +10,7 @@ from wled_controller.core.capture.calibration import CalibrationConfig, calibrat
from wled_controller.storage.color_strip_source import ( from wled_controller.storage.color_strip_source import (
ColorStripSource, ColorStripSource,
PictureColorStripSource, PictureColorStripSource,
StaticColorStripSource,
) )
from wled_controller.utils import get_logger from wled_controller.utils import get_logger
@@ -99,6 +100,7 @@ class ColorStripStore:
interpolation_mode: str = "average", interpolation_mode: str = "average",
calibration=None, calibration=None,
led_count: int = 0, led_count: int = 0,
color: Optional[list] = None,
description: Optional[str] = None, description: Optional[str] = None,
) -> ColorStripSource: ) -> ColorStripSource:
"""Create a new color strip source. """Create a new color strip source.
@@ -113,29 +115,41 @@ class ColorStripStore:
if source.name == name: if source.name == name:
raise ValueError(f"Color strip source with name '{name}' already exists") raise ValueError(f"Color strip source with name '{name}' already exists")
if calibration is None:
calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
source_id = f"css_{uuid.uuid4().hex[:8]}" source_id = f"css_{uuid.uuid4().hex[:8]}"
now = datetime.utcnow() now = datetime.utcnow()
source = PictureColorStripSource( if source_type == "static":
id=source_id, rgb = color if isinstance(color, list) and len(color) == 3 else [255, 255, 255]
name=name, source = StaticColorStripSource(
source_type=source_type, id=source_id,
created_at=now, name=name,
updated_at=now, source_type="static",
description=description, created_at=now,
picture_source_id=picture_source_id, updated_at=now,
fps=fps, description=description,
brightness=brightness, color=rgb,
saturation=saturation, led_count=led_count,
gamma=gamma, )
smoothing=smoothing, else:
interpolation_mode=interpolation_mode, if calibration is None:
calibration=calibration, calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left")
led_count=led_count, source = PictureColorStripSource(
) id=source_id,
name=name,
source_type=source_type,
created_at=now,
updated_at=now,
description=description,
picture_source_id=picture_source_id,
fps=fps,
brightness=brightness,
saturation=saturation,
gamma=gamma,
smoothing=smoothing,
interpolation_mode=interpolation_mode,
calibration=calibration,
led_count=led_count,
)
self._sources[source_id] = source self._sources[source_id] = source
self._save() self._save()
@@ -156,6 +170,7 @@ class ColorStripStore:
interpolation_mode: Optional[str] = None, interpolation_mode: Optional[str] = None,
calibration=None, calibration=None,
led_count: Optional[int] = None, led_count: Optional[int] = None,
color: Optional[list] = None,
description: Optional[str] = None, description: Optional[str] = None,
) -> ColorStripSource: ) -> ColorStripSource:
"""Update an existing color strip source. """Update an existing color strip source.
@@ -196,6 +211,12 @@ class ColorStripStore:
source.calibration = calibration source.calibration = calibration
if led_count is not None: if led_count is not None:
source.led_count = led_count source.led_count = led_count
elif isinstance(source, StaticColorStripSource):
if color is not None:
if isinstance(color, list) and len(color) == 3:
source.color = color
if led_count is not None:
source.led_count = led_count
source.updated_at = datetime.utcnow() source.updated_at = datetime.utcnow()
self._save() self._save()

View File

@@ -16,95 +16,123 @@
<div class="form-group"> <div class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-picture-source" data-i18n="color_strip.picture_source">Picture Source:</label> <label for="css-editor-type" data-i18n="color_strip.type">Type:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.picture_source.hint">Which screen capture source to use as input for LED color calculation</small> <small class="input-hint" style="display:none" data-i18n="color_strip.type.hint">Picture Source derives colors from a screen capture. Static Color fills all LEDs with one constant color.</small>
<select id="css-editor-picture-source"></select> <select id="css-editor-type" onchange="onCSSTypeChange()">
</div> <option value="picture" data-i18n="color_strip.type.picture">Picture Source</option>
<option value="static" data-i18n="color_strip.type.static">Static Color</option>
<div class="form-group">
<div class="label-row">
<label for="css-editor-fps">
<span data-i18n="color_strip.fps">Target FPS:</span>
<span id="css-editor-fps-value">30</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.fps.hint">Target frames per second for LED color updates (10-90)</small>
<div class="slider-row">
<input type="range" id="css-editor-fps" min="10" max="90" value="30" oninput="document.getElementById('css-editor-fps-value').textContent = this.value">
<span class="slider-value">fps</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-interpolation" data-i18n="color_strip.interpolation">Color Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.interpolation.hint">How to calculate LED color from sampled border pixels</small>
<select id="css-editor-interpolation">
<option value="average" data-i18n="color_strip.interpolation.average">Average</option>
<option value="median" data-i18n="color_strip.interpolation.median">Median</option>
<option value="dominant" data-i18n="color_strip.interpolation.dominant">Dominant</option>
</select> </select>
</div> </div>
<div class="form-group"> <!-- Picture-source-specific fields -->
<div class="label-row"> <div id="css-editor-picture-section">
<label for="css-editor-smoothing"> <div class="form-group">
<span data-i18n="color_strip.smoothing">Smoothing:</span> <div class="label-row">
<span id="css-editor-smoothing-value">0.30</span> <label for="css-editor-picture-source" data-i18n="color_strip.picture_source">Picture Source:</label>
</label> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.picture_source.hint">Which screen capture source to use as input for LED color calculation</small>
<select id="css-editor-picture-source"></select>
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.smoothing.hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
<input type="range" id="css-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('css-editor-smoothing-value').textContent = parseFloat(this.value).toFixed(2)"> <div class="form-group">
<div class="label-row">
<label for="css-editor-fps">
<span data-i18n="color_strip.fps">Target FPS:</span>
<span id="css-editor-fps-value">30</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.fps.hint">Target frames per second for LED color updates (10-90)</small>
<div class="slider-row">
<input type="range" id="css-editor-fps" min="10" max="90" value="30" oninput="document.getElementById('css-editor-fps-value').textContent = this.value">
<span class="slider-value">fps</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-interpolation" data-i18n="color_strip.interpolation">Color Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.interpolation.hint">How to calculate LED color from sampled border pixels</small>
<select id="css-editor-interpolation">
<option value="average" data-i18n="color_strip.interpolation.average">Average</option>
<option value="median" data-i18n="color_strip.interpolation.median">Median</option>
<option value="dominant" data-i18n="color_strip.interpolation.dominant">Dominant</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-smoothing">
<span data-i18n="color_strip.smoothing">Smoothing:</span>
<span id="css-editor-smoothing-value">0.30</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.smoothing.hint">Temporal blending between frames (0=none, 1=full). Reduces flicker.</small>
<input type="range" id="css-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('css-editor-smoothing-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<details class="form-collapse">
<summary data-i18n="color_strip.color_corrections">Color Corrections</summary>
<div class="form-collapse-body">
<div class="form-group">
<div class="label-row">
<label for="css-editor-brightness">
<span data-i18n="color_strip.brightness">Brightness:</span>
<span id="css-editor-brightness-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.brightness.hint">Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.</small>
<input type="range" id="css-editor-brightness" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-brightness-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-saturation">
<span data-i18n="color_strip.saturation">Saturation:</span>
<span id="css-editor-saturation-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.saturation.hint">Color saturation (0=grayscale, 1=unchanged, 2=double saturation)</small>
<input type="range" id="css-editor-saturation" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-saturation-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-gamma">
<span data-i18n="color_strip.gamma">Gamma:</span>
<span id="css-editor-gamma-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gamma.hint">Gamma correction (1=none, &lt;1=brighter midtones, &gt;1=darker midtones)</small>
<input type="range" id="css-editor-gamma" min="0.1" max="3.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-gamma-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
</div>
</details>
</div> </div>
<details class="form-collapse"> <!-- Static-color-specific fields -->
<summary data-i18n="color_strip.color_corrections">Color Corrections</summary> <div id="css-editor-static-section" style="display:none">
<div class="form-collapse-body"> <div class="form-group">
<div class="form-group"> <div class="label-row">
<div class="label-row"> <label for="css-editor-color" data-i18n="color_strip.static_color">Color:</label>
<label for="css-editor-brightness"> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
<span data-i18n="color_strip.brightness">Brightness:</span>
<span id="css-editor-brightness-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.brightness.hint">Output brightness multiplier (0=off, 1=unchanged, 2=double). Applied after color extraction.</small>
<input type="range" id="css-editor-brightness" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-brightness-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-saturation">
<span data-i18n="color_strip.saturation">Saturation:</span>
<span id="css-editor-saturation-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.saturation.hint">Color saturation (0=grayscale, 1=unchanged, 2=double saturation)</small>
<input type="range" id="css-editor-saturation" min="0.0" max="2.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-saturation-value').textContent = parseFloat(this.value).toFixed(2)">
</div>
<div class="form-group">
<div class="label-row">
<label for="css-editor-gamma">
<span data-i18n="color_strip.gamma">Gamma:</span>
<span id="css-editor-gamma-value">1.00</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="color_strip.gamma.hint">Gamma correction (1=none, &lt;1=brighter midtones, &gt;1=darker midtones)</small>
<input type="range" id="css-editor-gamma" min="0.1" max="3.0" step="0.05" value="1.0" oninput="document.getElementById('css-editor-gamma-value').textContent = parseFloat(this.value).toFixed(2)">
</div> </div>
<small class="input-hint" style="display:none" data-i18n="color_strip.static_color.hint">The solid color that will be sent to all LEDs on the strip.</small>
<input type="color" id="css-editor-color" value="#ffffff">
</div> </div>
</details> </div>
<div class="form-group"> <!-- LED count — picture type only (auto-sized from device for static) -->
<div id="css-editor-led-count-group" class="form-group">
<div class="label-row"> <div class="label-row">
<label for="css-editor-led-count" data-i18n="color_strip.led_count">LED Count:</label> <label for="css-editor-led-count" data-i18n="color_strip.led_count">LED Count:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button> <button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?" data-i18n-aria-label="aria.hint">?</button>