From 5f9bc9a37e57f41aade24d066b068224c0c6c86b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 12 Feb 2026 16:43:09 +0300 Subject: [PATCH] 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 --- .../api/routes/picture_targets.py | 215 +++++++- .../api/schemas/picture_targets.py | 55 +- .../wled_controller/core/processor_manager.py | 336 ++++++++++++- server/src/wled_controller/main.py | 14 +- server/src/wled_controller/static/app.js | 468 +++++++++++++++++- server/src/wled_controller/static/index.html | 84 ++++ .../wled_controller/static/locales/en.json | 37 +- .../wled_controller/static/locales/ru.json | 37 +- server/src/wled_controller/static/style.css | 123 +++++ .../storage/key_colors_picture_target.py | 97 ++++ .../wled_controller/storage/picture_target.py | 70 +-- .../storage/picture_target_store.py | 35 +- .../storage/wled_picture_target.py | 65 +++ 13 files changed, 1525 insertions(+), 111 deletions(-) create mode 100644 server/src/wled_controller/storage/key_colors_picture_target.py create mode 100644 server/src/wled_controller/storage/wled_picture_target.py diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index 4abe6bc..2c2efd4 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -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,20 +78,66 @@ 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() - 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, - ) + 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, + description=target.description, + created_at=target.created_at, + updated_at=target.updated_at, + ) # ===== CRUD ENDPOINTS ===== @@ -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,16 +302,21 @@ 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: - manager.remove_target(target_id) + if manager.is_kc_target(target_id): + manager.remove_kc_target(target_id) + else: + manager.remove_target(target_id) except (ValueError, RuntimeError): pass @@ -260,10 +343,13 @@ 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) - await manager.start_processing(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}") return {"status": "started", "target_id": target_id} @@ -285,7 +371,10 @@ async def stop_processing( ): """Stop processing for a picture target.""" try: - await manager.stop_processing(target_id) + 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}") return {"status": "stopped", "target_id": target_id} @@ -307,7 +396,10 @@ async def get_target_state( ): """Get current processing state for a target.""" try: - state = manager.get_target_state(target_id) + 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) except ValueError as e: @@ -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,7 +499,10 @@ async def get_target_metrics( ): """Get processing metrics for a target.""" try: - metrics = manager.get_target_metrics(target_id) + 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) except ValueError as e: @@ -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=.""" + # 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) diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index be513c2..0a6acf2 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -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") diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 51184a9..4e88c92 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -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 diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py index d38ce66..0dcfa9e 100644 --- a/server/src/wled_controller/main.py +++ b/server/src/wled_controller/main.py @@ -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)") diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 3cbbe88..4152968 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -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 = `
${subTabs.map(tab => @@ -4054,7 +4063,21 @@ async function loadTargetsTab() {
`; - container.innerHTML = tabBar + wledPanel; + // Key Colors panel + const kcPanel = ` +
+
+

${t('targets.section.key_colors')}

+
+ ${kcTargets.map(target => createKCTargetCard(target, sourceMap)).join('')} +
+
+
+
+
+
+
`; + + 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 = `
${t('targets.failed')}
`; @@ -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]) => ` +
+
+ ${escapeHtml(name)} +
+ `).join(''); + } else if (isProcessing) { + swatchesHtml = `${t('kc.colors.none')}`; + } + + return ` +
+ +
+
+ ${escapeHtml(target.name)} + KEY COLORS + ${isProcessing ? `${t('targets.status.processing')}` : ''} +
+
+
+ 📺 ${escapeHtml(sourceName)} + ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''} +
+
+
+ ${swatchesHtml} +
+ ${isProcessing ? ` +
+
+
${state.fps_actual?.toFixed(1) || '0.0'}
+
${t('targets.metrics.actual_fps')}
+
+
+
${state.fps_target || 0}
+
${t('targets.metrics.target_fps')}
+
+
+
${metrics.frames_processed || 0}
+
${t('targets.metrics.frames')}
+
+
+
${metrics.errors_count || 0}
+
${t('targets.metrics.errors')}
+
+
+ ` : ''} +
+
+ ${isProcessing ? ` + + ` : ` + + `} + +
+
+ `; +} + +// ===== 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 = `
${t('kc.rect.empty')}
`; + return; + } + + const labels = `
+ ${t('kc.rect.name')} + ${t('kc.rect.x')} + ${t('kc.rect.y')} + ${t('kc.rect.width')} + ${t('kc.rect.height')} + +
`; + + const rows = kcEditorRectangles.map((rect, i) => ` +
+ + + + + + +
+ `).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 = `${t('kc.colors.none')}`; + return; + } + + container.innerHTML = entries.map(([name, color]) => ` +
+
+ ${escapeHtml(name)} +
+ `).join(''); +} diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 5797497..c1809de 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -314,6 +314,90 @@ + + +