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.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:
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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}, "
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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')}">✕</button>
|
<button class="card-remove-btn" onclick="deleteColorStrip('${source.id}')" title="${t('common.delete')}">✕</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),
|
||||||
|
|||||||
@@ -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."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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": "Статический цвет, который будет отправлен на все светодиоды полосы."
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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, <1=brighter midtones, >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, <1=brighter midtones, >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>
|
||||||
|
|||||||
Reference in New Issue
Block a user