From 2a8e2daefc5b6635326f8e5bcd4b9d8b22ce9968 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Fri, 20 Feb 2026 17:49:48 +0300 Subject: [PATCH] CSS: add StaticColorStripSource type with auto-sized LED count MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../api/routes/color_strip_sources.py | 12 +- .../api/schemas/color_strip_sources.py | 20 +- .../core/processing/color_strip_stream.py | 69 +++++++ .../processing/color_strip_stream_manager.py | 21 +- .../core/processing/wled_target_processor.py | 6 + server/src/wled_controller/static/js/app.js | 2 + .../static/js/features/color-strips.js | 153 +++++++++++---- .../wled_controller/static/locales/en.json | 10 +- .../wled_controller/static/locales/ru.json | 10 +- .../storage/color_strip_source.py | 37 +++- .../storage/color_strip_store.py | 61 ++++-- .../templates/modals/css-editor.html | 184 ++++++++++-------- 12 files changed, 430 insertions(+), 155 deletions(-) diff --git a/server/src/wled_controller/api/routes/color_strip_sources.py b/server/src/wled_controller/api/routes/color_strip_sources.py index ec29a97..3d9ae3f 100644 --- a/server/src/wled_controller/api/routes/color_strip_sources.py +++ b/server/src/wled_controller/api/routes/color_strip_sources.py @@ -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.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.picture_source import ProcessedPictureSource, ScreenCapturePictureSource 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), led_count=getattr(source, "led_count", 0), calibration=calibration, + color=getattr(source, "color", None), description=source.description, overlay_active=overlay_active, created_at=source.created_at, @@ -117,6 +118,7 @@ async def create_color_strip_source( interpolation_mode=data.interpolation_mode, led_count=data.led_count, calibration=calibration, + color=data.color, description=data.description, ) return _css_to_response(source) @@ -169,6 +171,7 @@ async def update_color_strip_source( interpolation_mode=data.interpolation_mode, led_count=data.led_count, calibration=calibration, + color=data.color, description=data.description, ) @@ -255,6 +258,11 @@ async def test_css_calibration( if body.edges: try: 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: calibration = source.calibration except ValueError as e: @@ -296,6 +304,8 @@ async def start_css_overlay( """Start screen overlay visualization for a color strip source.""" try: 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): raise HTTPException(status_code=400, detail="Overlay only supported for picture color strip sources") if not source.calibration: diff --git a/server/src/wled_controller/api/schemas/color_strip_sources.py b/server/src/wled_controller/api/schemas/color_strip_sources.py index 3165b92..f7c2950 100644 --- a/server/src/wled_controller/api/schemas/color_strip_sources.py +++ b/server/src/wled_controller/api/schemas/color_strip_sources.py @@ -12,7 +12,8 @@ class ColorStripSourceCreate(BaseModel): """Request to create a color strip source.""" 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)") 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) @@ -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) 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)") - 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)") + # 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) @@ -29,6 +33,7 @@ class ColorStripSourceUpdate(BaseModel): """Request to update a color strip source.""" 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") 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) @@ -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) 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)") - led_count: Optional[int] = Field(None, description="Total LED count (0 = auto from calibration)", ge=0) 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) @@ -47,6 +55,7 @@ class ColorStripSourceResponse(BaseModel): id: str = Field(description="Source ID") name: str = Field(description="Source name") source_type: str = Field(description="Source type") + # picture-type fields picture_source_id: Optional[str] = Field(None, description="Picture source ID") fps: Optional[int] = Field(None, description="Target FPS") brightness: Optional[float] = Field(None, description="Brightness multiplier") @@ -54,8 +63,11 @@ class ColorStripSourceResponse(BaseModel): gamma: Optional[float] = Field(None, description="Gamma correction") smoothing: Optional[float] = Field(None, description="Temporal smoothing") 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") + # 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") overlay_active: bool = Field(False, description="Whether the screen overlay is currently active") created_at: datetime = Field(description="Creation timestamp") 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 4814ea7..4fc91a6 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream.py +++ b/server/src/wled_controller/core/processing/color_strip_stream.py @@ -331,3 +331,72 @@ class PictureColorStripStream(ColorStripStream): remaining = frame_time - elapsed if remaining > 0: 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") diff --git a/server/src/wled_controller/core/processing/color_strip_stream_manager.py b/server/src/wled_controller/core/processing/color_strip_stream_manager.py index 3367806..b084a36 100644 --- a/server/src/wled_controller/core/processing/color_strip_stream_manager.py +++ b/server/src/wled_controller/core/processing/color_strip_stream_manager.py @@ -15,6 +15,7 @@ from typing import Dict, Optional from wled_controller.core.processing.color_strip_stream import ( ColorStripStream, PictureColorStripStream, + StaticColorStripStream, ) from wled_controller.utils import get_logger @@ -78,10 +79,21 @@ class ColorStripStreamManager: ) 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) + 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): raise ValueError( 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, ) - 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 def release(self, css_id: str) -> None: @@ -140,8 +152,9 @@ class ColorStripStreamManager: del self._streams[css_id] logger.info(f"Removed color strip stream for source {css_id}") - # Release the underlying live stream - self._live_stream_manager.release(picture_source_id) + # Release the underlying live stream (not needed for static sources) + if picture_source_id: + self._live_stream_manager.release(picture_source_id) def update_source(self, css_id: str, new_source) -> None: """Hot-update processing params on a running stream. 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 a6cc88a..f8ae9a6 100644 --- a/server/src/wled_controller/core/processing/wled_target_processor.py +++ b/server/src/wled_controller/core/processing/wled_target_processor.py @@ -114,6 +114,12 @@ class WledTargetProcessor(TargetProcessor): self._color_strip_stream = stream self._resolved_display_index = stream.display_index 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( f"Acquired color strip stream for target {self._target_id} " f"(css={self._color_strip_source_id}, display={self._resolved_display_index}, " diff --git a/server/src/wled_controller/static/js/app.js b/server/src/wled_controller/static/js/app.js index 8ac3c47..b7c7004 100644 --- a/server/src/wled_controller/static/js/app.js +++ b/server/src/wled_controller/static/js/app.js @@ -90,6 +90,7 @@ import { // Layer 5: color-strip sources import { showCSSEditor, closeCSSEditorModal, forceCSSEditorClose, saveCSSEditor, deleteColorStrip, + onCSSTypeChange, } from './features/color-strips.js'; // Layer 5: calibration @@ -274,6 +275,7 @@ Object.assign(window, { forceCSSEditorClose, saveCSSEditor, deleteColorStrip, + onCSSTypeChange, // calibration showCalibration, diff --git a/server/src/wled_controller/static/js/features/color-strips.js b/server/src/wled_controller/static/js/features/color-strips.js index 2a14833..ba01979 100644 --- a/server/src/wled_controller/static/js/features/color-strips.js +++ b/server/src/wled_controller/static/js/features/color-strips.js @@ -13,8 +13,10 @@ class CSSEditorModal extends Modal { } snapshotValues() { + const type = document.getElementById('css-editor-type').value; return { name: document.getElementById('css-editor-name').value, + type, picture_source: document.getElementById('css-editor-picture-source').value, fps: document.getElementById('css-editor-fps').value, interpolation: document.getElementById('css-editor-interpolation').value, @@ -22,39 +24,81 @@ class CSSEditorModal extends Modal { brightness: document.getElementById('css-editor-brightness').value, saturation: document.getElementById('css-editor-saturation').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(); +/* ── 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 ─────────────────────────────────────────────────────── */ export function createColorStripCard(source, pictureSourceMap) { - 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; + const isStatic = source.source_type === 'static'; + + let propsHtml; + if (isStatic) { + const hexColor = rgbArrayToHex(source.color); + propsHtml = ` + + ${hexColor.toUpperCase()} + + ${source.led_count ? `💡 ${source.led_count}` : ''} + `; + } 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 = ` + ⚡ ${source.fps || 30} fps + ${ledCount ? `💡 ${ledCount}` : ''} + 📺 ${escapeHtml(srcName)} + `; + } + + const icon = isStatic ? '🎨' : '🎞️'; + const calibrationBtn = isStatic ? '' : ``; return `
- 🎞️ ${escapeHtml(source.name)} + ${icon} ${escapeHtml(source.name)}
- ⚡ ${source.fps || 30} fps - ${ledCount ? `💡 ${ledCount}` : ''} - 📺 ${escapeHtml(srcName)} + ${propsHtml}
- + ${calibrationBtn}
`; @@ -85,36 +129,46 @@ export async function showCSSEditor(cssId = null) { document.getElementById('css-editor-id').value = css.id; document.getElementById('css-editor-name').value = css.name; - sourceSelect.value = css.picture_source_id || ''; - const fps = css.fps ?? 30; - document.getElementById('css-editor-fps').value = fps; - document.getElementById('css-editor-fps-value').textContent = fps; + const sourceType = css.source_type || 'picture'; + document.getElementById('css-editor-type').value = sourceType; + 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; - document.getElementById('css-editor-smoothing').value = smoothing; - document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).toFixed(2); + const fps = css.fps ?? 30; + document.getElementById('css-editor-fps').value = fps; + document.getElementById('css-editor-fps-value').textContent = fps; - const brightness = css.brightness ?? 1.0; - document.getElementById('css-editor-brightness').value = brightness; - document.getElementById('css-editor-brightness-value').textContent = parseFloat(brightness).toFixed(2); + document.getElementById('css-editor-interpolation').value = css.interpolation_mode || 'average'; - 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 smoothing = css.smoothing ?? 0.3; + document.getElementById('css-editor-smoothing').value = smoothing; + document.getElementById('css-editor-smoothing-value').textContent = parseFloat(smoothing).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); + const brightness = css.brightness ?? 1.0; + document.getElementById('css-editor-brightness').value = brightness; + 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-title').textContent = t('color_strip.edit'); } else { document.getElementById('css-editor-id').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').textContent = '30'; 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-gamma').value = 1.0; 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-title').textContent = t('color_strip.add'); } @@ -150,23 +205,38 @@ export function isCSSEditorDirty() { return cssEditorModal.isDirty(); } export async function saveCSSEditor() { const cssId = document.getElementById('css-editor-id').value; const name = document.getElementById('css-editor-name').value.trim(); + const sourceType = document.getElementById('css-editor-type').value; if (!name) { cssEditorModal.showError(t('color_strip.error.name_required')); return; } - const 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, - }; + let payload; + if (sourceType === 'static') { + payload = { + name, + color: hexToRgbArray(document.getElementById('css-editor-color').value), + }; + if (!cssId) { + payload.source_type = 'static'; + } + } 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 { let response; @@ -176,7 +246,6 @@ export async function saveCSSEditor() { body: JSON.stringify(payload), }); } else { - payload.source_type = 'picture'; response = await fetchWithAuth('/color-strip-sources', { method: 'POST', body: JSON.stringify(payload), diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index 187dd4e..2d0ff05 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -563,11 +563,17 @@ "color_strip.test_device.hint": "Select a device to send test pixels to when clicking edge toggles", "color_strip.leds": "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.updated": "Color strip source updated", "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.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." } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 12a4f05..f27b73e 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -563,11 +563,17 @@ "color_strip.test_device.hint": "Выберите устройство для отправки тестовых пикселей при нажатии на рамку", "color_strip.leds": "Количество светодиодов", "color_strip.led_count": "Количество LED:", - "color_strip.led_count.hint": "Общее число светодиодов на физической полосе. 0 = взять из калибровки. Укажите явно, если на полосе есть светодиоды за телевизором, не привязанные к краям экрана — им будет отправлен чёрный цвет.", + "color_strip.led_count.hint": "Общее число светодиодов на физической полосе. Для источников экрана: 0 = автоматически из калибровки (светодиоды за ТВ будут чёрными). Для статического цвета: укажите точное количество светодиодов устройства.", "color_strip.created": "Источник цветовой полосы создан", "color_strip.updated": "Источник цветовой полосы обновлён", "color_strip.deleted": "Источник цветовой полосы удалён", "color_strip.delete.confirm": "Удалить этот источник цветовой полосы?", "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": "Статический цвет, который будет отправлен на все светодиоды полосы." } diff --git a/server/src/wled_controller/storage/color_strip_source.py b/server/src/wled_controller/storage/color_strip_source.py index 7782252..91233e7 100644 --- a/server/src/wled_controller/storage/color_strip_source.py +++ b/server/src/wled_controller/storage/color_strip_source.py @@ -6,9 +6,9 @@ calibration, color correction, smoothing, and FPS. Current types: PictureColorStripSource — derives LED colors from a PictureSource (screen capture) + StaticColorStripSource — constant solid color fills all LEDs Future types (not yet implemented): - StaticColorStripSource — constant solid colors GradientColorStripSource — animated gradient """ @@ -53,6 +53,7 @@ class ColorStripSource: "interpolation_mode": None, "calibration": None, "led_count": None, + "color": None, } @staticmethod @@ -85,7 +86,20 @@ class ColorStripSource: 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( id=sid, name=name, source_type=source_type, 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["led_count"] = self.led_count 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 diff --git a/server/src/wled_controller/storage/color_strip_store.py b/server/src/wled_controller/storage/color_strip_store.py index f204ae2..5044acd 100644 --- a/server/src/wled_controller/storage/color_strip_store.py +++ b/server/src/wled_controller/storage/color_strip_store.py @@ -10,6 +10,7 @@ from wled_controller.core.capture.calibration import CalibrationConfig, calibrat from wled_controller.storage.color_strip_source import ( ColorStripSource, PictureColorStripSource, + StaticColorStripSource, ) from wled_controller.utils import get_logger @@ -99,6 +100,7 @@ class ColorStripStore: interpolation_mode: str = "average", calibration=None, led_count: int = 0, + color: Optional[list] = None, description: Optional[str] = None, ) -> ColorStripSource: """Create a new color strip source. @@ -113,29 +115,41 @@ class ColorStripStore: if source.name == name: 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]}" now = datetime.utcnow() - 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, - ) + if source_type == "static": + rgb = color if isinstance(color, list) and len(color) == 3 else [255, 255, 255] + source = StaticColorStripSource( + id=source_id, + name=name, + source_type="static", + created_at=now, + updated_at=now, + description=description, + color=rgb, + led_count=led_count, + ) + else: + if calibration is None: + calibration = CalibrationConfig(layout="clockwise", start_position="bottom_left") + 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._save() @@ -156,6 +170,7 @@ class ColorStripStore: interpolation_mode: Optional[str] = None, calibration=None, led_count: Optional[int] = None, + color: Optional[list] = None, description: Optional[str] = None, ) -> ColorStripSource: """Update an existing color strip source. @@ -196,6 +211,12 @@ class ColorStripStore: source.calibration = calibration if led_count is not None: 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() self._save() diff --git a/server/src/wled_controller/templates/modals/css-editor.html b/server/src/wled_controller/templates/modals/css-editor.html index 97c3df8..bd473bd 100644 --- a/server/src/wled_controller/templates/modals/css-editor.html +++ b/server/src/wled_controller/templates/modals/css-editor.html @@ -16,95 +16,123 @@
- +
- - -
- -
-
- - -
- -
- - fps -
-
- -
-
- - -
- - + +
-
-
- - + +
+
+
+ + +
+ +
- - + +
+
+ + +
+ +
+ + fps +
+
+ +
+
+ + +
+ + +
+ +
+
+ + +
+ + +
+ +
+ Color Corrections +
+
+
+ + +
+ + +
+ +
+
+ + +
+ + +
+ +
+
+ + +
+ + +
+
+
-
- Color Corrections -
-
-
- - -
- - -
- -
-
- - -
- - -
- -
-
- - -
- - + +
+
-
+ +