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:
@@ -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=<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)
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)")
|
||||
|
||||
|
||||
@@ -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')}">✕</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')}">✕</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('');
|
||||
}
|
||||
|
||||
@@ -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">✕</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">✕</button>
|
||||
<button class="btn btn-icon btn-primary" onclick="saveKCEditor()" title="Save">✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Login Modal -->
|
||||
<div id="api-key-modal" class="modal">
|
||||
<div class="modal-content">
|
||||
|
||||
@@ -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.0–1.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"
|
||||
}
|
||||
|
||||
@@ -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.0–1.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": "Цвета пока не извлечены"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
|
||||
@@ -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())),
|
||||
)
|
||||
@@ -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())),
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
65
server/src/wled_controller/storage/wled_picture_target.py
Normal file
65
server/src/wled_controller/storage/wled_picture_target.py
Normal 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())),
|
||||
)
|
||||
Reference in New Issue
Block a user