Add Key Colors target type for extracting colors from screen regions

Introduce a new "key_colors" target type alongside WLED targets, enabling
real-time color extraction from configurable screen rectangles with
average/median/dominant modes, temporal smoothing, and WebSocket streaming.

- Split WledPictureTarget into its own module, add KeyColorsPictureTarget
- Add KC target lifecycle to ProcessorManager (register, start/stop, processing loop)
- Extend API routes and schemas for KC targets (CRUD, settings, state, metrics, colors)
- Add WebSocket endpoint for real-time color updates with auth
- Add KC sub-tab in Targets UI with editor modal and live color swatches
- Add EN and RU translations for all key colors strings

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 16:43:09 +03:00
parent 3d2393e474
commit 5f9bc9a37e
13 changed files with 1525 additions and 111 deletions

View File

@@ -1,6 +1,8 @@
"""Picture target routes: CRUD, processing control, settings, state, metrics."""
from fastapi import APIRouter, HTTPException, Depends
import secrets
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
@@ -9,6 +11,10 @@ from wled_controller.api.dependencies import (
get_processor_manager,
)
from wled_controller.api.schemas.picture_targets import (
ExtractedColorResponse,
KeyColorRectangleSchema,
KeyColorsResponse,
KeyColorsSettingsSchema,
PictureTargetCreate,
PictureTargetListResponse,
PictureTargetResponse,
@@ -17,9 +23,15 @@ from wled_controller.api.schemas.picture_targets import (
TargetMetricsResponse,
TargetProcessingState,
)
from wled_controller.config import config
from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings
from wled_controller.storage import DeviceStore
from wled_controller.storage.picture_target import WledPictureTarget
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import (
KeyColorRectangle,
KeyColorsSettings,
KeyColorsPictureTarget,
)
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.utils import get_logger
@@ -66,16 +78,62 @@ def _settings_to_schema(settings: ProcessingSettings) -> ProcessingSettingsSchem
)
def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSchema:
"""Convert core KeyColorsSettings to schema."""
return KeyColorsSettingsSchema(
fps=settings.fps,
interpolation_mode=settings.interpolation_mode,
smoothing=settings.smoothing,
rectangles=[
KeyColorRectangleSchema(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
for r in settings.rectangles
],
)
def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings:
"""Convert schema KeyColorsSettings to core."""
return KeyColorsSettings(
fps=schema.fps,
interpolation_mode=schema.interpolation_mode,
smoothing=schema.smoothing,
rectangles=[
KeyColorRectangle(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
for r in schema.rectangles
],
)
def _target_to_response(target) -> PictureTargetResponse:
"""Convert a PictureTarget to PictureTargetResponse."""
settings_schema = _settings_to_schema(target.settings) if isinstance(target, WledPictureTarget) else ProcessingSettingsSchema()
if isinstance(target, WledPictureTarget):
return PictureTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
device_id=target.device_id,
picture_source_id=target.picture_source_id,
settings=_settings_to_schema(target.settings),
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
)
elif isinstance(target, KeyColorsPictureTarget):
return PictureTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
picture_source_id=target.picture_source_id,
key_colors_settings=_kc_settings_to_schema(target.settings),
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
)
else:
return PictureTargetResponse(
id=target.id,
name=target.name,
target_type=target.target_type,
device_id=target.device_id if isinstance(target, WledPictureTarget) else "",
picture_source_id=target.picture_source_id if isinstance(target, WledPictureTarget) else "",
settings=settings_schema,
description=target.description,
created_at=target.created_at,
updated_at=target.updated_at,
@@ -102,6 +160,7 @@ async def create_target(
# Convert settings
core_settings = _settings_to_core(data.settings) if data.settings else None
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
# Create in store
target = target_store.create_target(
@@ -110,6 +169,7 @@ async def create_target(
device_id=data.device_id,
picture_source_id=data.picture_source_id,
settings=core_settings,
key_colors_settings=kc_settings,
description=data.description,
)
@@ -124,6 +184,15 @@ async def create_target(
)
except ValueError as e:
logger.warning(f"Could not register target {target.id} in processor manager: {e}")
elif isinstance(target, KeyColorsPictureTarget):
try:
manager.add_kc_target(
target_id=target.id,
picture_source_id=target.picture_source_id,
settings=target.settings,
)
except ValueError as e:
logger.warning(f"Could not register KC target {target.id}: {e}")
return _target_to_response(target)
@@ -180,6 +249,7 @@ async def update_target(
# Convert settings
core_settings = _settings_to_core(data.settings) if data.settings else None
kc_settings = _kc_schema_to_settings(data.key_colors_settings) if data.key_colors_settings else None
# Update in store
target = target_store.update_target(
@@ -188,6 +258,7 @@ async def update_target(
device_id=data.device_id,
picture_source_id=data.picture_source_id,
settings=core_settings,
key_colors_settings=kc_settings,
description=data.description,
)
@@ -201,7 +272,14 @@ async def update_target(
if data.device_id is not None:
manager.update_target_device(target_id, target.device_id)
except ValueError:
# Target may not be registered in manager yet
pass
elif isinstance(target, KeyColorsPictureTarget):
try:
if data.key_colors_settings is not None:
manager.update_kc_target_settings(target_id, target.settings)
if data.picture_source_id is not None:
manager.update_kc_target_source(target_id, target.picture_source_id)
except ValueError:
pass
return _target_to_response(target)
@@ -224,15 +302,20 @@ async def delete_target(
):
"""Delete a picture target. Stops processing first if active."""
try:
# Stop processing if running
# Stop processing if running (WLED or KC)
try:
if manager.is_target_processing(target_id):
if manager.is_kc_target(target_id):
await manager.stop_kc_processing(target_id)
elif manager.is_target_processing(target_id):
await manager.stop_processing(target_id)
except ValueError:
pass
# Remove from manager
# Remove from manager (WLED or KC)
try:
if manager.is_kc_target(target_id):
manager.remove_kc_target(target_id)
else:
manager.remove_target(target_id)
except (ValueError, RuntimeError):
pass
@@ -260,9 +343,12 @@ async def start_processing(
):
"""Start processing for a picture target."""
try:
# Verify target exists
target_store.get_target(target_id)
# Verify target exists and dispatch by type
target = target_store.get_target(target_id)
if isinstance(target, KeyColorsPictureTarget):
await manager.start_kc_processing(target_id)
else:
await manager.start_processing(target_id)
logger.info(f"Started processing for target {target_id}")
@@ -285,6 +371,9 @@ async def stop_processing(
):
"""Stop processing for a picture target."""
try:
if manager.is_kc_target(target_id):
await manager.stop_kc_processing(target_id)
else:
await manager.stop_processing(target_id)
logger.info(f"Stopped processing for target {target_id}")
@@ -307,6 +396,9 @@ async def get_target_state(
):
"""Get current processing state for a target."""
try:
if manager.is_kc_target(target_id):
state = manager.get_kc_target_state(target_id)
else:
state = manager.get_target_state(target_id)
return TargetProcessingState(**state)
@@ -317,7 +409,7 @@ async def get_target_state(
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-targets/{target_id}/settings", response_model=ProcessingSettingsSchema, tags=["Settings"])
@router.get("/api/v1/picture-targets/{target_id}/settings", tags=["Settings"])
async def get_target_settings(
target_id: str,
_auth: AuthRequired,
@@ -326,6 +418,8 @@ async def get_target_settings(
"""Get processing settings for a target."""
try:
target = target_store.get_target(target_id)
if isinstance(target, KeyColorsPictureTarget):
return _kc_settings_to_schema(target.settings)
if isinstance(target, WledPictureTarget):
return _settings_to_schema(target.settings)
return ProcessingSettingsSchema()
@@ -405,6 +499,9 @@ async def get_target_metrics(
):
"""Get processing metrics for a target."""
try:
if manager.is_kc_target(target_id):
metrics = manager.get_kc_target_metrics(target_id)
else:
metrics = manager.get_target_metrics(target_id)
return TargetMetricsResponse(**metrics)
@@ -413,3 +510,69 @@ async def get_target_metrics(
except Exception as e:
logger.error(f"Failed to get target metrics: {e}")
raise HTTPException(status_code=500, detail=str(e))
# ===== KEY COLORS ENDPOINTS =====
@router.get("/api/v1/picture-targets/{target_id}/colors", response_model=KeyColorsResponse, tags=["Key Colors"])
async def get_target_colors(
target_id: str,
_auth: AuthRequired,
manager: ProcessorManager = Depends(get_processor_manager),
):
"""Get latest extracted colors for a key-colors target (polling)."""
try:
raw_colors = manager.get_kc_latest_colors(target_id)
colors = {}
for name, (r, g, b) in raw_colors.items():
colors[name] = ExtractedColorResponse(
r=r, g=g, b=b,
hex=f"#{r:02x}{g:02x}{b:02x}",
)
from datetime import datetime
return KeyColorsResponse(
target_id=target_id,
colors=colors,
timestamp=datetime.utcnow(),
)
except ValueError as e:
raise HTTPException(status_code=404, detail=str(e))
@router.websocket("/api/v1/picture-targets/{target_id}/ws")
async def target_colors_ws(
websocket: WebSocket,
target_id: str,
token: str = Query(""),
):
"""WebSocket for real-time key color updates. Auth via ?token=<api_key>."""
# Authenticate
authenticated = False
if token and config.auth.api_keys:
for _label, api_key in config.auth.api_keys.items():
if secrets.compare_digest(token, api_key):
authenticated = True
break
if not authenticated:
await websocket.close(code=4001, reason="Unauthorized")
return
await websocket.accept()
manager = get_processor_manager()
try:
manager.add_kc_ws_client(target_id, websocket)
except ValueError:
await websocket.close(code=4004, reason="Target not found")
return
try:
while True:
# Keep alive — wait for client messages (or disconnect)
await websocket.receive_text()
except WebSocketDisconnect:
pass
finally:
manager.remove_kc_ws_client(target_id, websocket)

View File

@@ -35,14 +35,51 @@ class ProcessingSettings(BaseModel):
)
class KeyColorRectangleSchema(BaseModel):
"""A named rectangle for key color extraction (relative coords 0.0-1.0)."""
name: str = Field(description="Rectangle name", min_length=1, max_length=50)
x: float = Field(default=0.0, description="Left edge (0.0-1.0)", ge=0.0, le=1.0)
y: float = Field(default=0.0, description="Top edge (0.0-1.0)", ge=0.0, le=1.0)
width: float = Field(default=1.0, description="Width (0.0-1.0)", gt=0.0, le=1.0)
height: float = Field(default=1.0, description="Height (0.0-1.0)", gt=0.0, le=1.0)
class KeyColorsSettingsSchema(BaseModel):
"""Settings for key colors extraction."""
fps: int = Field(default=10, description="Extraction rate (1-60)", ge=1, le=60)
interpolation_mode: str = Field(default="average", description="Color mode (average, median, dominant)")
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
rectangles: List[KeyColorRectangleSchema] = Field(default_factory=list, description="Rectangles to extract colors from")
class ExtractedColorResponse(BaseModel):
"""A single extracted color."""
r: int = Field(description="Red (0-255)")
g: int = Field(description="Green (0-255)")
b: int = Field(description="Blue (0-255)")
hex: str = Field(description="Hex color (#rrggbb)")
class KeyColorsResponse(BaseModel):
"""Extracted key colors for a target."""
target_id: str = Field(description="Target ID")
colors: Dict[str, ExtractedColorResponse] = Field(description="Rectangle name -> color")
timestamp: Optional[datetime] = Field(None, description="Extraction timestamp")
class PictureTargetCreate(BaseModel):
"""Request to create a picture target."""
name: str = Field(description="Target name", min_length=1, max_length=100)
target_type: str = Field(default="wled", description="Target type (wled)")
target_type: str = Field(default="wled", description="Target type (wled, key_colors)")
device_id: str = Field(default="", description="WLED device ID")
picture_source_id: str = Field(default="", description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -52,7 +89,8 @@ class PictureTargetUpdate(BaseModel):
name: Optional[str] = Field(None, description="Target name", min_length=1, max_length=100)
device_id: Optional[str] = Field(None, description="WLED device ID")
picture_source_id: Optional[str] = Field(None, description="Picture source ID")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (for wled targets)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (for key_colors targets)")
description: Optional[str] = Field(None, description="Optional description", max_length=500)
@@ -64,7 +102,8 @@ class PictureTargetResponse(BaseModel):
target_type: str = Field(description="Target type")
device_id: str = Field(default="", description="WLED device ID")
picture_source_id: str = Field(default="", description="Picture source ID")
settings: ProcessingSettings = Field(description="Processing settings")
settings: Optional[ProcessingSettings] = Field(None, description="Processing settings (wled)")
key_colors_settings: Optional[KeyColorsSettingsSchema] = Field(None, description="Key colors settings (key_colors)")
description: Optional[str] = Field(None, description="Description")
created_at: datetime = Field(description="Creation timestamp")
updated_at: datetime = Field(description="Last update timestamp")
@@ -81,11 +120,11 @@ class TargetProcessingState(BaseModel):
"""Processing state for a picture target."""
target_id: str = Field(description="Target ID")
device_id: str = Field(description="Device ID")
device_id: Optional[str] = Field(None, description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS achieved")
fps_target: int = Field(description="Target FPS")
display_index: int = Field(description="Current display index")
fps_target: int = Field(default=0, description="Target FPS")
display_index: int = Field(default=0, description="Current display index")
last_update: Optional[datetime] = Field(None, description="Last successful update")
errors: List[str] = Field(default_factory=list, description="Recent errors")
wled_online: bool = Field(default=False, description="Whether WLED device is reachable")
@@ -103,7 +142,7 @@ class TargetMetricsResponse(BaseModel):
"""Target metrics response."""
target_id: str = Field(description="Target ID")
device_id: str = Field(description="Device ID")
device_id: Optional[str] = Field(None, description="Device ID")
processing: bool = Field(description="Whether processing is active")
fps_actual: Optional[float] = Field(None, description="Actual FPS")
fps_target: int = Field(description="Target FPS")

View File

@@ -1,6 +1,7 @@
"""Processing manager for coordinating screen capture and WLED updates."""
import asyncio
import json
import time
from dataclasses import dataclass, field
from datetime import datetime
@@ -18,7 +19,12 @@ from wled_controller.core.capture_engines.base import ScreenCapture
from wled_controller.core.live_stream import LiveStream
from wled_controller.core.live_stream_manager import LiveStreamManager
from wled_controller.core.pixel_processor import smooth_colors
from wled_controller.core.screen_capture import extract_border_pixels
from wled_controller.core.screen_capture import (
calculate_average_color,
calculate_dominant_color,
calculate_median_color,
extract_border_pixels,
)
from wled_controller.core.wled_client import WLEDClient
from wled_controller.utils import get_logger
@@ -125,6 +131,23 @@ class TargetState:
wled_state_before: Optional[dict] = None
@dataclass
class KeyColorsTargetState:
"""State of a running key-colors extractor processor."""
target_id: str
picture_source_id: str
settings: "KeyColorsSettings" # forward ref, resolved at runtime
is_running: bool = False
task: Optional[asyncio.Task] = None
metrics: ProcessingMetrics = field(default_factory=ProcessingMetrics)
live_stream: Optional[LiveStream] = None
previous_colors: Optional[Dict[str, Tuple[int, int, int]]] = None
latest_colors: Optional[Dict[str, Tuple[int, int, int]]] = None
ws_clients: list = field(default_factory=list) # List[WebSocket]
resolved_target_fps: Optional[int] = None
class ProcessorManager:
"""Manages screen processing for multiple WLED devices.
@@ -136,6 +159,7 @@ class ProcessorManager:
"""Initialize processor manager."""
self._devices: Dict[str, DeviceState] = {}
self._targets: Dict[str, TargetState] = {}
self._kc_targets: Dict[str, KeyColorsTargetState] = {}
self._health_monitoring_active = False
self._http_client: Optional[httpx.AsyncClient] = None
self._picture_source_store = picture_source_store
@@ -849,7 +873,7 @@ class ProcessorManager:
# Stop health monitoring
await self.stop_health_monitoring()
# Stop all targets
# Stop all WLED targets
target_ids = list(self._targets.keys())
for target_id in target_ids:
if self._targets[target_id].is_running:
@@ -858,6 +882,15 @@ class ProcessorManager:
except Exception as e:
logger.error(f"Error stopping target {target_id}: {e}")
# Stop all key-colors targets
kc_ids = list(self._kc_targets.keys())
for target_id in kc_ids:
if self._kc_targets[target_id].is_running:
try:
await self.stop_kc_processing(target_id)
except Exception as e:
logger.error(f"Error stopping KC target {target_id}: {e}")
# Safety net: release any remaining managed live streams
self._live_stream_manager.release_all()
@@ -971,3 +1004,302 @@ class ProcessorManager:
wled_led_type=state.health.wled_led_type,
error=str(e),
)
# ===== KEY COLORS TARGET MANAGEMENT =====
def add_kc_target(self, target_id: str, picture_source_id: str, settings) -> None:
"""Register a key-colors target for processing."""
if target_id in self._kc_targets:
raise ValueError(f"KC target {target_id} already registered")
self._kc_targets[target_id] = KeyColorsTargetState(
target_id=target_id,
picture_source_id=picture_source_id,
settings=settings,
)
logger.info(f"Registered KC target: {target_id}")
def remove_kc_target(self, target_id: str) -> None:
"""Unregister a key-colors target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id]
if state.is_running:
raise ValueError(f"Cannot remove KC target {target_id}: still running")
del self._kc_targets[target_id]
logger.info(f"Removed KC target: {target_id}")
def update_kc_target_settings(self, target_id: str, settings) -> None:
"""Update settings for a key-colors target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
self._kc_targets[target_id].settings = settings
logger.info(f"Updated KC target settings: {target_id}")
def update_kc_target_source(self, target_id: str, picture_source_id: str) -> None:
"""Update picture source for a key-colors target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
self._kc_targets[target_id].picture_source_id = picture_source_id
logger.info(f"Updated KC target source: {target_id}")
async def start_kc_processing(self, target_id: str) -> None:
"""Start key-colors extraction for a target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id]
if state.is_running:
raise ValueError(f"KC target {target_id} is already running")
if not state.picture_source_id:
raise ValueError(f"KC target {target_id} has no picture source assigned")
if not state.settings.rectangles:
raise ValueError(f"KC target {target_id} has no rectangles defined")
# Acquire live stream
try:
live_stream = await asyncio.to_thread(
self._live_stream_manager.acquire, state.picture_source_id
)
state.live_stream = live_stream
state.resolved_target_fps = live_stream.target_fps
logger.info(
f"Acquired live stream for KC target {target_id} "
f"(picture_source={state.picture_source_id})"
)
except Exception as e:
logger.error(f"Failed to initialize live stream for KC target {target_id}: {e}")
raise RuntimeError(f"Failed to initialize live stream: {e}")
# Reset metrics
state.metrics = ProcessingMetrics(start_time=datetime.utcnow())
state.previous_colors = None
state.latest_colors = None
# Start processing task
state.task = asyncio.create_task(self._kc_processing_loop(target_id))
state.is_running = True
logger.info(f"Started KC processing for target {target_id}")
async def stop_kc_processing(self, target_id: str) -> None:
"""Stop key-colors extraction for a target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id]
if not state.is_running:
logger.warning(f"KC processing not running for target {target_id}")
return
state.is_running = False
# Cancel task
if state.task:
state.task.cancel()
try:
await state.task
except asyncio.CancelledError:
pass
state.task = None
# Release live stream
if state.live_stream:
try:
self._live_stream_manager.release(state.picture_source_id)
except Exception as e:
logger.warning(f"Error releasing live stream for KC target: {e}")
state.live_stream = None
logger.info(f"Stopped KC processing for target {target_id}")
async def _kc_processing_loop(self, target_id: str) -> None:
"""Main processing loop for a key-colors target."""
state = self._kc_targets[target_id]
settings = state.settings
target_fps = state.resolved_target_fps or settings.fps
smoothing = settings.smoothing
# Select color calculation function
calc_fns = {
"average": calculate_average_color,
"median": calculate_median_color,
"dominant": calculate_dominant_color,
}
calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color)
frame_time = 1.0 / target_fps
fps_samples: List[float] = []
logger.info(
f"KC processing loop started for target {target_id} "
f"(fps={target_fps}, rects={len(settings.rectangles)})"
)
try:
while state.is_running:
loop_start = time.time()
try:
capture = await asyncio.to_thread(state.live_stream.get_latest_frame)
if capture is None:
await asyncio.sleep(frame_time)
continue
img = capture.image
h, w = img.shape[:2]
colors: Dict[str, Tuple[int, int, int]] = {}
for rect in settings.rectangles:
# Convert relative coords to pixel coords
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
px_w = max(1, int(rect.width * w))
px_h = max(1, int(rect.height * h))
# Clamp to image bounds
px_x = min(px_x, w - 1)
px_y = min(px_y, h - 1)
px_w = min(px_w, w - px_x)
px_h = min(px_h, h - px_y)
# Extract sub-image and compute color
sub_img = img[px_y:px_y + px_h, px_x:px_x + px_w]
color = calc_fn(sub_img)
colors[rect.name] = color
# Apply per-rectangle temporal smoothing
if state.previous_colors and smoothing > 0:
for name, color in colors.items():
if name in state.previous_colors:
prev = state.previous_colors[name]
alpha = smoothing
colors[name] = (
int(color[0] * (1 - alpha) + prev[0] * alpha),
int(color[1] * (1 - alpha) + prev[1] * alpha),
int(color[2] * (1 - alpha) + prev[2] * alpha),
)
state.previous_colors = dict(colors)
state.latest_colors = dict(colors)
# Broadcast to WebSocket clients
await self._broadcast_kc_colors(target_id, colors)
# Update metrics
state.metrics.frames_processed += 1
state.metrics.last_update = datetime.utcnow()
loop_time = time.time() - loop_start
fps_samples.append(1.0 / loop_time if loop_time > 0 else 0)
if len(fps_samples) > 10:
fps_samples.pop(0)
state.metrics.fps_actual = sum(fps_samples) / len(fps_samples)
except Exception as e:
state.metrics.errors_count += 1
state.metrics.last_error = str(e)
logger.error(f"KC processing error for {target_id}: {e}", exc_info=True)
# FPS control
elapsed = time.time() - loop_start
sleep_time = max(0, frame_time - elapsed)
if sleep_time > 0:
await asyncio.sleep(sleep_time)
except asyncio.CancelledError:
logger.info(f"KC processing loop cancelled for target {target_id}")
raise
except Exception as e:
logger.error(f"Fatal error in KC processing loop for target {target_id}: {e}")
state.is_running = False
raise
finally:
logger.info(f"KC processing loop ended for target {target_id}")
async def _broadcast_kc_colors(self, target_id: str, colors: Dict[str, Tuple[int, int, int]]) -> None:
"""Broadcast extracted colors to WebSocket clients."""
state = self._kc_targets.get(target_id)
if not state or not state.ws_clients:
return
message = json.dumps({
"type": "colors_update",
"target_id": target_id,
"colors": {
name: {"r": c[0], "g": c[1], "b": c[2]}
for name, c in colors.items()
},
"timestamp": datetime.utcnow().isoformat(),
})
disconnected = []
for ws in state.ws_clients:
try:
await ws.send_text(message)
except Exception:
disconnected.append(ws)
for ws in disconnected:
state.ws_clients.remove(ws)
def add_kc_ws_client(self, target_id: str, ws) -> None:
"""Add a WebSocket client for KC color updates."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
self._kc_targets[target_id].ws_clients.append(ws)
def remove_kc_ws_client(self, target_id: str, ws) -> None:
"""Remove a WebSocket client."""
state = self._kc_targets.get(target_id)
if state and ws in state.ws_clients:
state.ws_clients.remove(ws)
def get_kc_target_state(self, target_id: str) -> dict:
"""Get current state for a KC target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id]
return {
"target_id": target_id,
"processing": state.is_running,
"fps_actual": round(state.metrics.fps_actual, 1) if state.is_running else None,
"fps_target": state.resolved_target_fps or state.settings.fps,
"last_update": state.metrics.last_update.isoformat() if state.metrics.last_update else None,
"errors": [state.metrics.last_error] if state.metrics.last_error else [],
}
def get_kc_target_metrics(self, target_id: str) -> dict:
"""Get metrics for a KC target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
state = self._kc_targets[target_id]
uptime = 0.0
if state.metrics.start_time and state.is_running:
uptime = (datetime.utcnow() - state.metrics.start_time).total_seconds()
return {
"target_id": target_id,
"processing": state.is_running,
"fps_actual": round(state.metrics.fps_actual, 1),
"fps_target": state.resolved_target_fps or state.settings.fps,
"uptime_seconds": round(uptime, 1),
"frames_processed": state.metrics.frames_processed,
"errors_count": state.metrics.errors_count,
"last_error": state.metrics.last_error,
"last_update": state.metrics.last_update.isoformat() if state.metrics.last_update else None,
}
def get_kc_latest_colors(self, target_id: str) -> Dict[str, Tuple[int, int, int]]:
"""Get latest extracted colors for a KC target."""
if target_id not in self._kc_targets:
raise ValueError(f"KC target {target_id} not found")
return self._kc_targets[target_id].latest_colors or {}
def is_kc_target(self, target_id: str) -> bool:
"""Check if a target ID belongs to a KC target."""
return target_id in self._kc_targets

View File

@@ -19,7 +19,8 @@ from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_target_store import PictureTargetStore
from wled_controller.storage.picture_target import WledPictureTarget
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import KeyColorsPictureTarget
from wled_controller.utils import setup_logging, get_logger
# Initialize logging
@@ -172,6 +173,17 @@ async def lifespan(app: FastAPI):
logger.info(f"Registered target: {target.name} ({target.id})")
except Exception as e:
logger.error(f"Failed to register target {target.id}: {e}")
elif isinstance(target, KeyColorsPictureTarget):
try:
processor_manager.add_kc_target(
target_id=target.id,
picture_source_id=target.picture_source_id,
settings=target.settings,
)
registered_targets += 1
logger.info(f"Registered KC target: {target.name} ({target.id})")
except Exception as e:
logger.error(f"Failed to register KC target {target.id}: {e}")
logger.info(f"Registered {registered_targets} picture target(s)")

View File

@@ -3998,7 +3998,7 @@ async function loadTargetsTab() {
})
);
// Fetch state + metrics for each target
// Fetch state + metrics for each target (+ colors for KC targets)
const targetsWithState = await Promise.all(
targets.map(async (target) => {
try {
@@ -4006,7 +4006,14 @@ async function loadTargetsTab() {
const state = stateResp.ok ? await stateResp.json() : {};
const metricsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/metrics`, { headers: getHeaders() });
const metrics = metricsResp.ok ? await metricsResp.json() : {};
return { ...target, state, metrics };
let latestColors = null;
if (target.target_type === 'key_colors' && state.processing) {
try {
const colorsResp = await fetch(`${API_BASE}/picture-targets/${target.id}/colors`, { headers: getHeaders() });
if (colorsResp.ok) latestColors = await colorsResp.json();
} catch {}
}
return { ...target, state, metrics, latestColors };
} catch {
return target;
}
@@ -4017,14 +4024,16 @@ async function loadTargetsTab() {
const deviceMap = {};
devicesWithState.forEach(d => { deviceMap[d.id] = d; });
// Group by type (currently only WLED)
// Group by type
const wledDevices = devicesWithState;
const wledTargets = targetsWithState.filter(t => t.target_type === 'wled');
const kcTargets = targetsWithState.filter(t => t.target_type === 'key_colors');
const activeSubTab = localStorage.getItem('activeTargetSubTab') || 'wled';
const subTabs = [
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length },
];
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
@@ -4054,7 +4063,21 @@ async function loadTargetsTab() {
</div>
</div>`;
container.innerHTML = tabBar + wledPanel;
// Key Colors panel
const kcPanel = `
<div class="target-sub-tab-panel stream-tab-panel${activeSubTab === 'key_colors' ? ' active' : ''}" id="target-sub-tab-key_colors">
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, sourceMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</div>
</div>
</div>
</div>
</div>`;
container.innerHTML = tabBar + wledPanel + kcPanel;
// Attach event listeners and fetch WLED brightness for device cards
devicesWithState.forEach(device => {
@@ -4062,6 +4085,21 @@ async function loadTargetsTab() {
fetchDeviceBrightness(device.id);
});
// Manage KC WebSockets: connect for processing, disconnect for stopped
const processingKCIds = new Set();
kcTargets.forEach(target => {
if (target.state && target.state.processing) {
processingKCIds.add(target.id);
if (!kcWebSockets[target.id]) {
connectKCWebSocket(target.id);
}
}
});
// Disconnect WebSockets for targets no longer processing
Object.keys(kcWebSockets).forEach(id => {
if (!processingKCIds.has(id)) disconnectKCWebSocket(id);
});
} catch (error) {
console.error('Failed to load targets tab:', error);
container.innerHTML = `<div class="loading">${t('targets.failed')}</div>`;
@@ -4203,3 +4241,425 @@ async function deleteTarget(targetId) {
showToast('Failed to delete target', 'error');
}
}
// ===== KEY COLORS TARGET CARD =====
function createKCTargetCard(target, sourceMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const kcSettings = target.key_colors_settings || {};
const isProcessing = state.processing || false;
const source = sourceMap[target.picture_source_id];
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
const rectCount = (kcSettings.rectangles || []).length;
// Render initial color swatches from pre-fetched REST data
let swatchesHtml = '';
const latestColors = target.latestColors && target.latestColors.colors;
if (isProcessing && latestColors && Object.keys(latestColors).length > 0) {
swatchesHtml = Object.entries(latestColors).map(([name, color]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
} else if (isProcessing) {
swatchesHtml = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
}
return `
<div class="card" data-kc-target-id="${target.id}">
<button class="card-remove-btn" onclick="deleteKCTarget('${target.id}')" title="${t('common.delete')}">&#x2715;</button>
<div class="card-header">
<div class="card-title">
${escapeHtml(target.name)}
<span class="badge">KEY COLORS</span>
${isProcessing ? `<span class="badge processing">${t('targets.status.processing')}</span>` : ''}
</div>
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop" title="${t('kc.rectangles')}">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
</div>
<div class="card-content">
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
${swatchesHtml}
</div>
${isProcessing ? `
<div class="metrics-grid">
<div class="metric">
<div class="metric-value">${state.fps_actual?.toFixed(1) || '0.0'}</div>
<div class="metric-label">${t('targets.metrics.actual_fps')}</div>
</div>
<div class="metric">
<div class="metric-value">${state.fps_target || 0}</div>
<div class="metric-label">${t('targets.metrics.target_fps')}</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.frames_processed || 0}</div>
<div class="metric-label">${t('targets.metrics.frames')}</div>
</div>
<div class="metric">
<div class="metric-value">${metrics.errors_count || 0}</div>
<div class="metric-label">${t('targets.metrics.errors')}</div>
</div>
</div>
` : ''}
</div>
<div class="card-actions">
${isProcessing ? `
<button class="btn btn-icon btn-danger" onclick="stopTargetProcessing('${target.id}')" title="${t('targets.button.stop')}">
⏹️
</button>
` : `
<button class="btn btn-icon btn-primary" onclick="startTargetProcessing('${target.id}')" title="${t('targets.button.start')}">
▶️
</button>
`}
<button class="btn btn-icon btn-secondary" onclick="showKCEditor('${target.id}')" title="${t('common.edit')}">
✏️
</button>
</div>
</div>
`;
}
// ===== KEY COLORS EDITOR =====
let kcEditorRectangles = [];
let kcEditorInitialValues = {};
let _kcNameManuallyEdited = false;
function _autoGenerateKCName() {
if (_kcNameManuallyEdited) return;
if (document.getElementById('kc-editor-id').value) return; // editing, not creating
const sourceSelect = document.getElementById('kc-editor-source');
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
if (!sourceName) return;
const rectCount = kcEditorRectangles.length;
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
const modeName = t(`kc.interpolation.${mode}`);
document.getElementById('kc-editor-name').value = `${sourceName} [${rectCount}](${modeName})`;
}
function addKCRectangle(name = '', x = 0.0, y = 0.0, width = 1.0, height = 1.0) {
kcEditorRectangles.push({ name: name || `Zone ${kcEditorRectangles.length + 1}`, x, y, width, height });
renderKCRectangles();
_autoGenerateKCName();
}
function removeKCRectangle(index) {
kcEditorRectangles.splice(index, 1);
renderKCRectangles();
_autoGenerateKCName();
}
function renderKCRectangles() {
const container = document.getElementById('kc-rect-list');
if (!container) return;
if (kcEditorRectangles.length === 0) {
container.innerHTML = `<div class="kc-rect-empty">${t('kc.rect.empty')}</div>`;
return;
}
const labels = `<div class="kc-rect-labels">
<span>${t('kc.rect.name')}</span>
<span>${t('kc.rect.x')}</span>
<span>${t('kc.rect.y')}</span>
<span>${t('kc.rect.width')}</span>
<span>${t('kc.rect.height')}</span>
<span></span>
</div>`;
const rows = kcEditorRectangles.map((rect, i) => `
<div class="kc-rect-row">
<input type="text" value="${escapeHtml(rect.name)}" placeholder="${t('kc.rect.name')}" onchange="kcEditorRectangles[${i}].name = this.value">
<input type="number" value="${rect.x}" min="0" max="1" step="0.01" onchange="kcEditorRectangles[${i}].x = parseFloat(this.value) || 0">
<input type="number" value="${rect.y}" min="0" max="1" step="0.01" onchange="kcEditorRectangles[${i}].y = parseFloat(this.value) || 0">
<input type="number" value="${rect.width}" min="0.01" max="1" step="0.01" onchange="kcEditorRectangles[${i}].width = parseFloat(this.value) || 0.01">
<input type="number" value="${rect.height}" min="0.01" max="1" step="0.01" onchange="kcEditorRectangles[${i}].height = parseFloat(this.value) || 0.01">
<button type="button" class="kc-rect-remove-btn" onclick="removeKCRectangle(${i})" title="${t('kc.rect.remove')}">&#x2715;</button>
</div>
`).join('');
container.innerHTML = labels + rows;
}
async function showKCEditor(targetId = null) {
try {
// Load sources for dropdown
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
// Populate source select (no empty option — source is required for KC targets)
const sourceSelect = document.getElementById('kc-editor-source');
sourceSelect.innerHTML = '';
sources.forEach(s => {
const opt = document.createElement('option');
opt.value = s.id;
opt.dataset.name = s.name;
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
opt.textContent = `${typeIcon} ${s.name}`;
sourceSelect.appendChild(opt);
});
if (targetId) {
// Editing existing target
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
const kcSettings = target.key_colors_settings || {};
document.getElementById('kc-editor-id').value = target.id;
document.getElementById('kc-editor-name').value = target.name;
sourceSelect.value = target.picture_source_id || '';
document.getElementById('kc-editor-fps').value = kcSettings.fps ?? 10;
document.getElementById('kc-editor-fps-value').textContent = kcSettings.fps ?? 10;
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
document.getElementById('kc-editor-title').textContent = t('kc.edit');
kcEditorRectangles = (kcSettings.rectangles || []).map(r => ({ ...r }));
} else {
// Creating new target
document.getElementById('kc-editor-id').value = '';
document.getElementById('kc-editor-name').value = '';
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
document.getElementById('kc-editor-fps').value = 10;
document.getElementById('kc-editor-fps-value').textContent = '10';
document.getElementById('kc-editor-interpolation').value = 'average';
document.getElementById('kc-editor-smoothing').value = 0.3;
document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
document.getElementById('kc-editor-title').textContent = t('kc.add');
kcEditorRectangles = [];
}
renderKCRectangles();
// Auto-name: reset flag and wire listeners
_kcNameManuallyEdited = !!targetId; // treat edit mode as manually edited
document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; };
sourceSelect.onchange = () => _autoGenerateKCName();
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
// Trigger auto-name after dropdowns are populated (create mode only)
if (!targetId) _autoGenerateKCName();
kcEditorInitialValues = {
name: document.getElementById('kc-editor-name').value,
source: sourceSelect.value,
fps: document.getElementById('kc-editor-fps').value,
interpolation: document.getElementById('kc-editor-interpolation').value,
smoothing: document.getElementById('kc-editor-smoothing').value,
rectangles: JSON.stringify(kcEditorRectangles),
};
const modal = document.getElementById('kc-editor-modal');
modal.style.display = 'flex';
lockBody();
setupBackdropClose(modal, closeKCEditorModal);
document.getElementById('kc-editor-error').style.display = 'none';
setTimeout(() => document.getElementById('kc-editor-name').focus(), 100);
} catch (error) {
console.error('Failed to open KC editor:', error);
showToast('Failed to open key colors editor', 'error');
}
}
function isKCEditorDirty() {
return (
document.getElementById('kc-editor-name').value !== kcEditorInitialValues.name ||
document.getElementById('kc-editor-source').value !== kcEditorInitialValues.source ||
document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps ||
document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation ||
document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing ||
JSON.stringify(kcEditorRectangles) !== kcEditorInitialValues.rectangles
);
}
async function closeKCEditorModal() {
if (isKCEditorDirty()) {
const confirmed = await showConfirm(t('modal.discard_changes'));
if (!confirmed) return;
}
forceCloseKCEditorModal();
}
function forceCloseKCEditorModal() {
document.getElementById('kc-editor-modal').style.display = 'none';
document.getElementById('kc-editor-error').style.display = 'none';
unlockBody();
kcEditorInitialValues = {};
kcEditorRectangles = [];
}
async function saveKCEditor() {
const targetId = document.getElementById('kc-editor-id').value;
const name = document.getElementById('kc-editor-name').value.trim();
const sourceId = document.getElementById('kc-editor-source').value;
const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
const interpolation = document.getElementById('kc-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
const errorEl = document.getElementById('kc-editor-error');
if (!name) {
errorEl.textContent = t('kc.error.required');
errorEl.style.display = 'block';
return;
}
if (kcEditorRectangles.length === 0) {
errorEl.textContent = t('kc.error.no_rectangles');
errorEl.style.display = 'block';
return;
}
const payload = {
name,
picture_source_id: sourceId,
key_colors_settings: {
fps,
interpolation_mode: interpolation,
smoothing,
rectangles: kcEditorRectangles.map(r => ({
name: r.name,
x: r.x,
y: r.y,
width: r.width,
height: r.height,
})),
},
};
try {
let response;
if (targetId) {
response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'PUT',
headers: getHeaders(),
body: JSON.stringify(payload),
});
} else {
payload.target_type = 'key_colors';
response = await fetch(`${API_BASE}/picture-targets`, {
method: 'POST',
headers: getHeaders(),
body: JSON.stringify(payload),
});
}
if (response.status === 401) { handle401Error(); return; }
if (!response.ok) {
const err = await response.json();
throw new Error(err.detail || 'Failed to save');
}
showToast(targetId ? t('kc.updated') : t('kc.created'), 'success');
forceCloseKCEditorModal();
await loadTargets();
} catch (error) {
console.error('Error saving KC target:', error);
errorEl.textContent = error.message;
errorEl.style.display = 'block';
}
}
async function deleteKCTarget(targetId) {
const confirmed = await showConfirm(t('kc.delete.confirm'));
if (!confirmed) return;
try {
disconnectKCWebSocket(targetId);
const response = await fetch(`${API_BASE}/picture-targets/${targetId}`, {
method: 'DELETE',
headers: getHeaders()
});
if (response.status === 401) { handle401Error(); return; }
if (response.ok) {
showToast(t('kc.deleted'), 'success');
loadTargets();
} else {
const error = await response.json();
showToast(`Failed to delete: ${error.detail}`, 'error');
}
} catch (error) {
showToast('Failed to delete key colors target', 'error');
}
}
// ===== KEY COLORS WEBSOCKET =====
const kcWebSockets = {};
function connectKCWebSocket(targetId) {
// Disconnect existing connection if any
disconnectKCWebSocket(targetId);
const key = localStorage.getItem('wled_api_key');
if (!key) return;
const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
const wsUrl = `${protocol}//${window.location.host}${API_BASE}/picture-targets/${targetId}/ws?token=${encodeURIComponent(key)}`;
try {
const ws = new WebSocket(wsUrl);
ws.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
updateKCColorSwatches(targetId, data.colors || {});
} catch (e) {
console.error('Failed to parse KC WebSocket message:', e);
}
};
ws.onclose = () => {
delete kcWebSockets[targetId];
};
ws.onerror = (error) => {
console.error(`KC WebSocket error for ${targetId}:`, error);
};
kcWebSockets[targetId] = ws;
} catch (error) {
console.error(`Failed to connect KC WebSocket for ${targetId}:`, error);
}
}
function disconnectKCWebSocket(targetId) {
const ws = kcWebSockets[targetId];
if (ws) {
ws.close();
delete kcWebSockets[targetId];
}
}
function disconnectAllKCWebSockets() {
Object.keys(kcWebSockets).forEach(targetId => disconnectKCWebSocket(targetId));
}
function updateKCColorSwatches(targetId, colors) {
const container = document.getElementById(`kc-swatches-${targetId}`);
if (!container) return;
const entries = Object.entries(colors);
if (entries.length === 0) {
container.innerHTML = `<span class="kc-no-colors">${t('kc.colors.none')}</span>`;
return;
}
container.innerHTML = entries.map(([name, color]) => `
<div class="kc-swatch">
<div class="kc-swatch-color" style="background-color: ${color.hex}" title="${color.hex}"></div>
<span class="kc-swatch-label" title="${escapeHtml(name)}">${escapeHtml(name)}</span>
</div>
`).join('');
}

View File

@@ -314,6 +314,90 @@
</div>
</div>
<!-- Key Colors Editor Modal -->
<div id="kc-editor-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h2 id="kc-editor-title" data-i18n="kc.add">🎨 Add Key Colors Target</h2>
<button class="modal-close-btn" onclick="closeKCEditorModal()" title="Close">&#x2715;</button>
</div>
<div class="modal-body">
<form id="kc-editor-form">
<input type="hidden" id="kc-editor-id">
<div class="form-group">
<label for="kc-editor-name" data-i18n="kc.name">Target Name:</label>
<input type="text" id="kc-editor-name" data-i18n-placeholder="kc.name.placeholder" placeholder="My Key Colors Target" required>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-source" data-i18n="kc.source">Picture Source:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.source.hint">Which picture source to extract colors from</small>
<select id="kc-editor-source"></select>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-fps" data-i18n="kc.fps">Extraction FPS:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.fps.hint">How many times per second to extract colors (1-60)</small>
<div class="slider-row">
<input type="range" id="kc-editor-fps" min="1" max="60" value="10" oninput="document.getElementById('kc-editor-fps-value').textContent = this.value">
<span id="kc-editor-fps-value" class="slider-value">10</span>
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-interpolation" data-i18n="kc.interpolation">Color Mode:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.interpolation.hint">How to compute the key color from pixels in each rectangle</small>
<select id="kc-editor-interpolation">
<option value="average">Average</option>
<option value="median">Median</option>
<option value="dominant">Dominant</option>
</select>
</div>
<div class="form-group">
<div class="label-row">
<label for="kc-editor-smoothing">
<span data-i18n="kc.smoothing">Smoothing:</span>
<span id="kc-editor-smoothing-value">0.3</span>
</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.smoothing.hint">Temporal blending between extractions (0=none, 1=full)</small>
<input type="range" id="kc-editor-smoothing" min="0.0" max="1.0" step="0.05" value="0.3" oninput="document.getElementById('kc-editor-smoothing-value').textContent = this.value">
</div>
<div class="form-group">
<div class="label-row">
<label data-i18n="kc.rectangles">Color Rectangles</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.rectangles.hint">Define named rectangles in relative coordinates (0.0-1.0) on the captured image</small>
<div id="kc-rect-list" class="kc-rect-list"></div>
<button type="button" class="btn btn-secondary kc-add-rect-btn" onclick="addKCRectangle()">
+ <span data-i18n="kc.rect.add">Add Rectangle</span>
</button>
</div>
<div id="kc-editor-error" class="error-message" style="display: none;"></div>
</form>
</div>
<div class="modal-footer">
<button class="btn btn-icon btn-secondary" onclick="closeKCEditorModal()" title="Cancel">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="saveKCEditor()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Login Modal -->
<div id="api-key-modal" class="modal">
<div class="modal-content">

View File

@@ -341,5 +341,40 @@
"targets.metrics.actual_fps": "Actual FPS",
"targets.metrics.target_fps": "Target FPS",
"targets.metrics.frames": "Frames",
"targets.metrics.errors": "Errors"
"targets.metrics.errors": "Errors",
"targets.subtab.key_colors": "Key Colors",
"targets.section.key_colors": "🎨 Key Colors Targets",
"kc.add": "Add Key Colors Target",
"kc.edit": "Edit Key Colors Target",
"kc.name": "Target Name:",
"kc.name.placeholder": "My Key Colors Target",
"kc.source": "Picture Source:",
"kc.source.hint": "Which picture source to extract colors from",
"kc.source.none": "-- No source assigned --",
"kc.fps": "Extraction FPS:",
"kc.fps.hint": "How many times per second to extract colors (1-60)",
"kc.interpolation": "Color Mode:",
"kc.interpolation.hint": "How to compute the key color from pixels in each rectangle",
"kc.interpolation.average": "Average",
"kc.interpolation.median": "Median",
"kc.interpolation.dominant": "Dominant",
"kc.smoothing": "Smoothing:",
"kc.smoothing.hint": "Temporal blending between extractions (0=none, 1=full)",
"kc.rectangles": "Color Rectangles",
"kc.rectangles.hint": "Define named rectangles in relative coordinates (0.01.0) on the captured image",
"kc.rect.name": "Name",
"kc.rect.x": "X",
"kc.rect.y": "Y",
"kc.rect.width": "W",
"kc.rect.height": "H",
"kc.rect.add": "Add Rectangle",
"kc.rect.remove": "Remove",
"kc.rect.empty": "No rectangles defined. Add at least one rectangle to extract colors.",
"kc.created": "Key colors target created successfully",
"kc.updated": "Key colors target updated successfully",
"kc.deleted": "Key colors target deleted successfully",
"kc.delete.confirm": "Are you sure you want to delete this key colors target?",
"kc.error.no_rectangles": "Please add at least one rectangle",
"kc.error.required": "Please fill in all required fields",
"kc.colors.none": "No colors extracted yet"
}

View File

@@ -341,5 +341,40 @@
"targets.metrics.actual_fps": "Факт. FPS",
"targets.metrics.target_fps": "Целев. FPS",
"targets.metrics.frames": "Кадры",
"targets.metrics.errors": "Ошибки"
"targets.metrics.errors": "Ошибки",
"targets.subtab.key_colors": "Ключевые Цвета",
"targets.section.key_colors": "🎨 Цели Ключевых Цветов",
"kc.add": "Добавить Цель Ключевых Цветов",
"kc.edit": "Редактировать Цель Ключевых Цветов",
"kc.name": "Имя Цели:",
"kc.name.placeholder": "Моя Цель Ключевых Цветов",
"kc.source": "Источник:",
"kc.source.hint": "Из какого источника извлекать цвета",
"kc.source.none": "-- Источник не назначен --",
"kc.fps": "FPS Извлечения:",
"kc.fps.hint": "Сколько раз в секунду извлекать цвета (1-60)",
"kc.interpolation": "Режим Цвета:",
"kc.interpolation.hint": "Как вычислять ключевой цвет из пикселей в каждом прямоугольнике",
"kc.interpolation.average": "Среднее",
"kc.interpolation.median": "Медиана",
"kc.interpolation.dominant": "Доминантный",
"kc.smoothing": "Сглаживание:",
"kc.smoothing.hint": "Временное смешивание между извлечениями (0=нет, 1=полное)",
"kc.rectangles": "Цветовые Прямоугольники",
"kc.rectangles.hint": "Определите именованные прямоугольники в относительных координатах (0.01.0) на захваченном изображении",
"kc.rect.name": "Имя",
"kc.rect.x": "X",
"kc.rect.y": "Y",
"kc.rect.width": "Ш",
"kc.rect.height": "В",
"kc.rect.add": "Добавить Прямоугольник",
"kc.rect.remove": "Удалить",
"kc.rect.empty": "Прямоугольники не определены. Добавьте хотя бы один для извлечения цветов.",
"kc.created": "Цель ключевых цветов успешно создана",
"kc.updated": "Цель ключевых цветов успешно обновлена",
"kc.deleted": "Цель ключевых цветов успешно удалена",
"kc.delete.confirm": "Вы уверены, что хотите удалить эту цель ключевых цветов?",
"kc.error.no_rectangles": "Пожалуйста, добавьте хотя бы один прямоугольник",
"kc.error.required": "Пожалуйста, заполните все обязательные поля",
"kc.colors.none": "Цвета пока не извлечены"
}

View File

@@ -2583,3 +2583,126 @@ input:-webkit-autofill:focus {
font-size: 0.7rem;
}
/* Key Colors target styles */
.kc-rect-list {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 8px;
}
.kc-rect-row {
display: flex;
align-items: center;
gap: 6px;
padding: 8px;
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
}
.kc-rect-row input[type="text"] {
flex: 2;
min-width: 0;
}
.kc-rect-row input[type="number"] {
flex: 1;
min-width: 0;
width: 60px;
}
.kc-rect-row .kc-rect-remove-btn {
background: none;
border: none;
color: #777;
font-size: 1rem;
cursor: pointer;
padding: 4px;
border-radius: 4px;
flex-shrink: 0;
transition: color 0.2s, background 0.2s;
}
.kc-rect-row .kc-rect-remove-btn:hover {
color: var(--danger-color);
background: rgba(244, 67, 54, 0.1);
}
.kc-rect-labels {
display: flex;
gap: 6px;
padding: 0 8px;
margin-bottom: 4px;
font-size: 0.7rem;
color: var(--text-secondary);
font-weight: 600;
}
.kc-rect-labels span:first-child {
flex: 2;
}
.kc-rect-labels span {
flex: 1;
text-align: center;
}
.kc-rect-labels span:last-child {
width: 28px;
flex: 0 0 28px;
}
.kc-add-rect-btn {
width: 100%;
font-size: 0.85rem;
padding: 6px 12px;
}
.kc-rect-empty {
text-align: center;
color: var(--text-secondary);
font-size: 0.85rem;
padding: 12px;
font-style: italic;
}
.kc-color-swatches {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-bottom: 8px;
}
.kc-swatch {
display: flex;
flex-direction: column;
align-items: center;
gap: 3px;
}
.kc-swatch-color {
width: 32px;
height: 32px;
border-radius: 6px;
border: 2px solid var(--border-color);
transition: background-color 0.3s;
}
.kc-swatch-label {
font-size: 0.6rem;
color: var(--text-secondary);
max-width: 40px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
text-align: center;
}
.kc-no-colors {
color: var(--text-secondary);
font-size: 0.8rem;
font-style: italic;
padding: 4px 0;
}

View File

@@ -0,0 +1,97 @@
"""Key colors picture target — extracts key colors from image rectangles."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import List, Optional
from wled_controller.storage.picture_target import PictureTarget
@dataclass
class KeyColorRectangle:
"""A named rectangle in relative coordinates (0.0 to 1.0)."""
name: str
x: float
y: float
width: float
height: float
def to_dict(self) -> dict:
return {
"name": self.name,
"x": self.x,
"y": self.y,
"width": self.width,
"height": self.height,
}
@classmethod
def from_dict(cls, data: dict) -> "KeyColorRectangle":
return cls(
name=data["name"],
x=float(data.get("x", 0.0)),
y=float(data.get("y", 0.0)),
width=float(data.get("width", 1.0)),
height=float(data.get("height", 1.0)),
)
@dataclass
class KeyColorsSettings:
"""Settings for key colors extraction."""
fps: int = 10
interpolation_mode: str = "average"
smoothing: float = 0.3
rectangles: List[KeyColorRectangle] = field(default_factory=list)
def to_dict(self) -> dict:
return {
"fps": self.fps,
"interpolation_mode": self.interpolation_mode,
"smoothing": self.smoothing,
"rectangles": [r.to_dict() for r in self.rectangles],
}
@classmethod
def from_dict(cls, data: dict) -> "KeyColorsSettings":
return cls(
fps=data.get("fps", 10),
interpolation_mode=data.get("interpolation_mode", "average"),
smoothing=data.get("smoothing", 0.3),
rectangles=[
KeyColorRectangle.from_dict(r)
for r in data.get("rectangles", [])
],
)
@dataclass
class KeyColorsPictureTarget(PictureTarget):
"""Key colors extractor target — extracts key colors from image rectangles."""
picture_source_id: str = ""
settings: KeyColorsSettings = field(default_factory=KeyColorsSettings)
def to_dict(self) -> dict:
d = super().to_dict()
d["picture_source_id"] = self.picture_source_id
d["settings"] = self.settings.to_dict()
return d
@classmethod
def from_dict(cls, data: dict) -> "KeyColorsPictureTarget":
settings_data = data.get("settings", {})
settings = KeyColorsSettings.from_dict(settings_data)
return cls(
id=data["id"],
name=data["name"],
target_type="key_colors",
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)

View File

@@ -1,12 +1,9 @@
"""Picture target data models."""
"""Picture target base data model."""
import uuid
from dataclasses import dataclass, field
from dataclasses import dataclass
from datetime import datetime
from typing import Optional
from wled_controller.core.processor_manager import ProcessingSettings
@dataclass
class PictureTarget:
@@ -14,7 +11,7 @@ class PictureTarget:
id: str
name: str
target_type: str # "wled" (future: "artnet", "e131", ...)
target_type: str # "wled", "key_colors", ...
created_at: datetime
updated_at: datetime
description: Optional[str] = None
@@ -35,62 +32,9 @@ class PictureTarget:
"""Create from dictionary, dispatching to the correct subclass."""
target_type = data.get("target_type", "wled")
if target_type == "wled":
from wled_controller.storage.wled_picture_target import WledPictureTarget
return WledPictureTarget.from_dict(data)
if target_type == "key_colors":
from wled_controller.storage.key_colors_picture_target import KeyColorsPictureTarget
return KeyColorsPictureTarget.from_dict(data)
raise ValueError(f"Unknown target type: {target_type}")
@dataclass
class WledPictureTarget(PictureTarget):
"""WLED picture target — streams a picture source to a WLED device."""
device_id: str = ""
picture_source_id: str = ""
settings: ProcessingSettings = field(default_factory=ProcessingSettings)
def to_dict(self) -> dict:
"""Convert to dictionary."""
d = super().to_dict()
d["device_id"] = self.device_id
d["picture_source_id"] = self.picture_source_id
d["settings"] = {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
"state_check_interval": self.settings.state_check_interval,
}
return d
@classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary."""
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
settings_data = data.get("settings", {})
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
return cls(
id=data["id"],
name=data["name"],
target_type=data.get("target_type", "wled"),
device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)

View File

@@ -7,7 +7,12 @@ from pathlib import Path
from typing import Dict, List, Optional
from wled_controller.core.processor_manager import ProcessingSettings
from wled_controller.storage.picture_target import PictureTarget, WledPictureTarget
from wled_controller.storage.picture_target import PictureTarget
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import (
KeyColorsSettings,
KeyColorsPictureTarget,
)
from wled_controller.utils import get_logger
logger = get_logger(__name__)
@@ -97,22 +102,24 @@ class PictureTargetStore:
device_id: str = "",
picture_source_id: str = "",
settings: Optional[ProcessingSettings] = None,
key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None,
) -> PictureTarget:
"""Create a new picture target.
Args:
name: Target name
target_type: Target type ("wled")
target_type: Target type ("wled", "key_colors")
device_id: WLED device ID (for wled targets)
picture_source_id: Picture source ID
settings: Processing settings
settings: Processing settings (for wled targets)
key_colors_settings: Key colors settings (for key_colors targets)
description: Optional description
Raises:
ValueError: If validation fails
"""
if target_type not in ("wled",):
if target_type not in ("wled", "key_colors"):
raise ValueError(f"Invalid target type: {target_type}")
# Check for duplicate name
@@ -135,6 +142,17 @@ class PictureTargetStore:
created_at=now,
updated_at=now,
)
elif target_type == "key_colors":
target = KeyColorsPictureTarget(
id=target_id,
name=name,
target_type="key_colors",
picture_source_id=picture_source_id,
settings=key_colors_settings or KeyColorsSettings(),
description=description,
created_at=now,
updated_at=now,
)
else:
raise ValueError(f"Unknown target type: {target_type}")
@@ -151,6 +169,7 @@ class PictureTargetStore:
device_id: Optional[str] = None,
picture_source_id: Optional[str] = None,
settings: Optional[ProcessingSettings] = None,
key_colors_settings: Optional[KeyColorsSettings] = None,
description: Optional[str] = None,
) -> PictureTarget:
"""Update a picture target.
@@ -181,6 +200,12 @@ class PictureTargetStore:
if settings is not None:
target.settings = settings
if isinstance(target, KeyColorsPictureTarget):
if picture_source_id is not None:
target.picture_source_id = picture_source_id
if key_colors_settings is not None:
target.settings = key_colors_settings
target.updated_at = datetime.utcnow()
self._save()
@@ -211,7 +236,7 @@ class PictureTargetStore:
def is_referenced_by_source(self, source_id: str) -> bool:
"""Check if any target references a picture source."""
for target in self._targets.values():
if isinstance(target, WledPictureTarget) and target.picture_source_id == source_id:
if isinstance(target, (WledPictureTarget, KeyColorsPictureTarget)) and target.picture_source_id == source_id:
return True
return False

View File

@@ -0,0 +1,65 @@
"""WLED picture target — streams a picture source to a WLED device."""
from dataclasses import dataclass, field
from datetime import datetime
from typing import Optional
from wled_controller.core.processor_manager import ProcessingSettings
from wled_controller.storage.picture_target import PictureTarget
@dataclass
class WledPictureTarget(PictureTarget):
"""WLED picture target — streams a picture source to a WLED device."""
device_id: str = ""
picture_source_id: str = ""
settings: ProcessingSettings = field(default_factory=ProcessingSettings)
def to_dict(self) -> dict:
"""Convert to dictionary."""
d = super().to_dict()
d["device_id"] = self.device_id
d["picture_source_id"] = self.picture_source_id
d["settings"] = {
"display_index": self.settings.display_index,
"fps": self.settings.fps,
"border_width": self.settings.border_width,
"brightness": self.settings.brightness,
"gamma": self.settings.gamma,
"saturation": self.settings.saturation,
"smoothing": self.settings.smoothing,
"interpolation_mode": self.settings.interpolation_mode,
"state_check_interval": self.settings.state_check_interval,
}
return d
@classmethod
def from_dict(cls, data: dict) -> "WledPictureTarget":
"""Create from dictionary."""
from wled_controller.core.processor_manager import DEFAULT_STATE_CHECK_INTERVAL
settings_data = data.get("settings", {})
settings = ProcessingSettings(
display_index=settings_data.get("display_index", 0),
fps=settings_data.get("fps", 30),
border_width=settings_data.get("border_width", 10),
brightness=settings_data.get("brightness", 1.0),
gamma=settings_data.get("gamma", 2.2),
saturation=settings_data.get("saturation", 1.0),
smoothing=settings_data.get("smoothing", 0.3),
interpolation_mode=settings_data.get("interpolation_mode", "average"),
state_check_interval=settings_data.get("state_check_interval", DEFAULT_STATE_CHECK_INTERVAL),
)
return cls(
id=data["id"],
name=data["name"],
target_type=data.get("target_type", "wled"),
device_id=data.get("device_id", ""),
picture_source_id=data.get("picture_source_id", ""),
settings=settings,
description=data.get("description"),
created_at=datetime.fromisoformat(data.get("created_at", datetime.utcnow().isoformat())),
updated_at=datetime.fromisoformat(data.get("updated_at", datetime.utcnow().isoformat())),
)