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:
@@ -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:
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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,7 +152,8 @@ class ColorStripStreamManager:
|
||||
del self._streams[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)
|
||||
if picture_source_id:
|
||||
self._live_stream_manager.release(picture_source_id)
|
||||
|
||||
def update_source(self, css_id: str, new_source) -> None:
|
||||
|
||||
@@ -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}, "
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 isStatic = source.source_type === 'static';
|
||||
|
||||
let propsHtml;
|
||||
if (isStatic) {
|
||||
const hexColor = rgbArrayToHex(source.color);
|
||||
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 `
|
||||
<div class="card" data-css-id="${source.id}">
|
||||
<button class="card-remove-btn" onclick="deleteColorStrip('${source.id}')" title="${t('common.delete')}">✕</button>
|
||||
<div class="card-header">
|
||||
<div class="card-title">
|
||||
🎞️ ${escapeHtml(source.name)}
|
||||
${icon} ${escapeHtml(source.name)}
|
||||
</div>
|
||||
</div>
|
||||
<div class="stream-card-props">
|
||||
<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>
|
||||
${propsHtml}
|
||||
</div>
|
||||
<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="showCSSCalibration('${source.id}')" title="${t('calibration.title')}">📐</button>
|
||||
${calibrationBtn}
|
||||
</div>
|
||||
</div>
|
||||
`;
|
||||
@@ -85,6 +129,14 @@ export async function showCSSEditor(cssId = null) {
|
||||
|
||||
document.getElementById('css-editor-id').value = css.id;
|
||||
document.getElementById('css-editor-name').value = css.name;
|
||||
|
||||
const sourceType = css.source_type || 'picture';
|
||||
document.getElementById('css-editor-type').value = sourceType;
|
||||
onCSSTypeChange();
|
||||
|
||||
if (sourceType === 'static') {
|
||||
document.getElementById('css-editor-color').value = rgbArrayToHex(css.color);
|
||||
} else {
|
||||
sourceSelect.value = css.picture_source_id || '';
|
||||
|
||||
const fps = css.fps ?? 30;
|
||||
@@ -108,13 +160,15 @@ export async function showCSSEditor(cssId = null) {
|
||||
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,13 +205,24 @@ 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 = {
|
||||
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,
|
||||
@@ -167,6 +233,10 @@ export async function saveCSSEditor() {
|
||||
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),
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
|
||||
@@ -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": "Статический цвет, который будет отправлен на все светодиоды полосы."
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,12 +115,24 @@ 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()
|
||||
|
||||
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,
|
||||
@@ -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()
|
||||
|
||||
@@ -14,6 +14,20 @@
|
||||
<input type="text" id="css-editor-name" data-i18n-placeholder="color_strip.name.placeholder" placeholder="Wall Strip" required>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<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>
|
||||
</div>
|
||||
<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-type" onchange="onCSSTypeChange()">
|
||||
<option value="picture" data-i18n="color_strip.type.picture">Picture Source</option>
|
||||
<option value="static" data-i18n="color_strip.type.static">Static Color</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<!-- Picture-source-specific fields -->
|
||||
<div id="css-editor-picture-section">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-picture-source" data-i18n="color_strip.picture_source">Picture Source:</label>
|
||||
@@ -103,8 +117,22 @@
|
||||
</div>
|
||||
</div>
|
||||
</details>
|
||||
</div>
|
||||
|
||||
<!-- Static-color-specific fields -->
|
||||
<div id="css-editor-static-section" style="display:none">
|
||||
<div class="form-group">
|
||||
<div class="label-row">
|
||||
<label for="css-editor-color" data-i18n="color_strip.static_color">Color:</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.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>
|
||||
|
||||
<!-- 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">
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user