Add Pattern Templates for Key Colors targets with visual canvas editor
Introduce Pattern Template entity as a reusable rectangle layout that Key Colors targets reference via pattern_template_id. This replaces inline rectangle storage with a shared template system. Backend: - New PatternTemplate data model, store (JSON persistence), CRUD API - KC targets now reference pattern_template_id instead of inline rectangles - ProcessorManager resolves pattern template at KC processing start - Picture source test endpoint supports capture_duration=0 for single frame - Delete protection: 409 when template is referenced by a KC target Frontend: - Pattern Templates section in Key Colors sub-tab with card UI - Visual canvas editor with drag-to-move, 8-point resize handles - Background capture from any picture source for visual alignment - Precise coordinate list synced bidirectionally with canvas - Resizable editor container, viewport-constrained modal - KC target editor uses pattern template dropdown instead of inline rects - Localization (en/ru) for all new UI elements Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -7,6 +7,7 @@ from .routes.devices import router as devices_router
|
|||||||
from .routes.templates import router as templates_router
|
from .routes.templates import router as templates_router
|
||||||
from .routes.postprocessing import router as postprocessing_router
|
from .routes.postprocessing import router as postprocessing_router
|
||||||
from .routes.picture_sources import router as picture_sources_router
|
from .routes.picture_sources import router as picture_sources_router
|
||||||
|
from .routes.pattern_templates import router as pattern_templates_router
|
||||||
from .routes.picture_targets import router as picture_targets_router
|
from .routes.picture_targets import router as picture_targets_router
|
||||||
|
|
||||||
router = APIRouter()
|
router = APIRouter()
|
||||||
@@ -14,6 +15,7 @@ router.include_router(system_router)
|
|||||||
router.include_router(devices_router)
|
router.include_router(devices_router)
|
||||||
router.include_router(templates_router)
|
router.include_router(templates_router)
|
||||||
router.include_router(postprocessing_router)
|
router.include_router(postprocessing_router)
|
||||||
|
router.include_router(pattern_templates_router)
|
||||||
router.include_router(picture_sources_router)
|
router.include_router(picture_sources_router)
|
||||||
router.include_router(picture_targets_router)
|
router.include_router(picture_targets_router)
|
||||||
|
|
||||||
|
|||||||
@@ -4,6 +4,7 @@ from wled_controller.core.processor_manager import ProcessorManager
|
|||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
from wled_controller.storage.template_store import TemplateStore
|
from wled_controller.storage.template_store import TemplateStore
|
||||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||||
|
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||||
|
|
||||||
@@ -11,6 +12,7 @@ from wled_controller.storage.picture_target_store import PictureTargetStore
|
|||||||
_device_store: DeviceStore | None = None
|
_device_store: DeviceStore | None = None
|
||||||
_template_store: TemplateStore | None = None
|
_template_store: TemplateStore | None = None
|
||||||
_pp_template_store: PostprocessingTemplateStore | None = None
|
_pp_template_store: PostprocessingTemplateStore | None = None
|
||||||
|
_pattern_template_store: PatternTemplateStore | None = None
|
||||||
_picture_source_store: PictureSourceStore | None = None
|
_picture_source_store: PictureSourceStore | None = None
|
||||||
_picture_target_store: PictureTargetStore | None = None
|
_picture_target_store: PictureTargetStore | None = None
|
||||||
_processor_manager: ProcessorManager | None = None
|
_processor_manager: ProcessorManager | None = None
|
||||||
@@ -37,6 +39,13 @@ def get_pp_template_store() -> PostprocessingTemplateStore:
|
|||||||
return _pp_template_store
|
return _pp_template_store
|
||||||
|
|
||||||
|
|
||||||
|
def get_pattern_template_store() -> PatternTemplateStore:
|
||||||
|
"""Get pattern template store dependency."""
|
||||||
|
if _pattern_template_store is None:
|
||||||
|
raise RuntimeError("Pattern template store not initialized")
|
||||||
|
return _pattern_template_store
|
||||||
|
|
||||||
|
|
||||||
def get_picture_source_store() -> PictureSourceStore:
|
def get_picture_source_store() -> PictureSourceStore:
|
||||||
"""Get picture source store dependency."""
|
"""Get picture source store dependency."""
|
||||||
if _picture_source_store is None:
|
if _picture_source_store is None:
|
||||||
@@ -63,15 +72,17 @@ def init_dependencies(
|
|||||||
template_store: TemplateStore,
|
template_store: TemplateStore,
|
||||||
processor_manager: ProcessorManager,
|
processor_manager: ProcessorManager,
|
||||||
pp_template_store: PostprocessingTemplateStore | None = None,
|
pp_template_store: PostprocessingTemplateStore | None = None,
|
||||||
|
pattern_template_store: PatternTemplateStore | None = None,
|
||||||
picture_source_store: PictureSourceStore | None = None,
|
picture_source_store: PictureSourceStore | None = None,
|
||||||
picture_target_store: PictureTargetStore | None = None,
|
picture_target_store: PictureTargetStore | None = None,
|
||||||
):
|
):
|
||||||
"""Initialize global dependencies."""
|
"""Initialize global dependencies."""
|
||||||
global _device_store, _template_store, _processor_manager
|
global _device_store, _template_store, _processor_manager
|
||||||
global _pp_template_store, _picture_source_store, _picture_target_store
|
global _pp_template_store, _pattern_template_store, _picture_source_store, _picture_target_store
|
||||||
_device_store = device_store
|
_device_store = device_store
|
||||||
_template_store = template_store
|
_template_store = template_store
|
||||||
_processor_manager = processor_manager
|
_processor_manager = processor_manager
|
||||||
_pp_template_store = pp_template_store
|
_pp_template_store = pp_template_store
|
||||||
|
_pattern_template_store = pattern_template_store
|
||||||
_picture_source_store = picture_source_store
|
_picture_source_store = picture_source_store
|
||||||
_picture_target_store = picture_target_store
|
_picture_target_store = picture_target_store
|
||||||
|
|||||||
147
server/src/wled_controller/api/routes/pattern_templates.py
Normal file
147
server/src/wled_controller/api/routes/pattern_templates.py
Normal file
@@ -0,0 +1,147 @@
|
|||||||
|
"""Pattern template routes: CRUD for rectangle layout templates."""
|
||||||
|
|
||||||
|
from fastapi import APIRouter, HTTPException, Depends
|
||||||
|
|
||||||
|
from wled_controller.api.auth import AuthRequired
|
||||||
|
from wled_controller.api.dependencies import (
|
||||||
|
get_pattern_template_store,
|
||||||
|
get_picture_target_store,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.pattern_templates import (
|
||||||
|
PatternTemplateCreate,
|
||||||
|
PatternTemplateListResponse,
|
||||||
|
PatternTemplateResponse,
|
||||||
|
PatternTemplateUpdate,
|
||||||
|
)
|
||||||
|
from wled_controller.api.schemas.picture_targets import KeyColorRectangleSchema
|
||||||
|
from wled_controller.storage.key_colors_picture_target import KeyColorRectangle
|
||||||
|
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||||
|
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
def _pat_template_to_response(t) -> PatternTemplateResponse:
|
||||||
|
"""Convert a PatternTemplate to its API response."""
|
||||||
|
return PatternTemplateResponse(
|
||||||
|
id=t.id,
|
||||||
|
name=t.name,
|
||||||
|
rectangles=[
|
||||||
|
KeyColorRectangleSchema(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
|
||||||
|
for r in t.rectangles
|
||||||
|
],
|
||||||
|
created_at=t.created_at,
|
||||||
|
updated_at=t.updated_at,
|
||||||
|
description=t.description,
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/pattern-templates", response_model=PatternTemplateListResponse, tags=["Pattern Templates"])
|
||||||
|
async def list_pattern_templates(
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: PatternTemplateStore = Depends(get_pattern_template_store),
|
||||||
|
):
|
||||||
|
"""List all pattern templates."""
|
||||||
|
try:
|
||||||
|
templates = store.get_all_templates()
|
||||||
|
responses = [_pat_template_to_response(t) for t in templates]
|
||||||
|
return PatternTemplateListResponse(templates=responses, count=len(responses))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to list pattern templates: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/api/v1/pattern-templates", response_model=PatternTemplateResponse, tags=["Pattern Templates"], status_code=201)
|
||||||
|
async def create_pattern_template(
|
||||||
|
data: PatternTemplateCreate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: PatternTemplateStore = Depends(get_pattern_template_store),
|
||||||
|
):
|
||||||
|
"""Create a new pattern template."""
|
||||||
|
try:
|
||||||
|
rectangles = [
|
||||||
|
KeyColorRectangle(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
|
||||||
|
for r in data.rectangles
|
||||||
|
]
|
||||||
|
template = store.create_template(
|
||||||
|
name=data.name,
|
||||||
|
rectangles=rectangles,
|
||||||
|
description=data.description,
|
||||||
|
)
|
||||||
|
return _pat_template_to_response(template)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to create pattern template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"])
|
||||||
|
async def get_pattern_template(
|
||||||
|
template_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: PatternTemplateStore = Depends(get_pattern_template_store),
|
||||||
|
):
|
||||||
|
"""Get pattern template by ID."""
|
||||||
|
try:
|
||||||
|
template = store.get_template(template_id)
|
||||||
|
return _pat_template_to_response(template)
|
||||||
|
except ValueError:
|
||||||
|
raise HTTPException(status_code=404, detail=f"Pattern template {template_id} not found")
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/api/v1/pattern-templates/{template_id}", response_model=PatternTemplateResponse, tags=["Pattern Templates"])
|
||||||
|
async def update_pattern_template(
|
||||||
|
template_id: str,
|
||||||
|
data: PatternTemplateUpdate,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: PatternTemplateStore = Depends(get_pattern_template_store),
|
||||||
|
):
|
||||||
|
"""Update a pattern template."""
|
||||||
|
try:
|
||||||
|
rectangles = None
|
||||||
|
if data.rectangles is not None:
|
||||||
|
rectangles = [
|
||||||
|
KeyColorRectangle(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
|
||||||
|
for r in data.rectangles
|
||||||
|
]
|
||||||
|
template = store.update_template(
|
||||||
|
template_id=template_id,
|
||||||
|
name=data.name,
|
||||||
|
rectangles=rectangles,
|
||||||
|
description=data.description,
|
||||||
|
)
|
||||||
|
return _pat_template_to_response(template)
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to update pattern template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/api/v1/pattern-templates/{template_id}", status_code=204, tags=["Pattern Templates"])
|
||||||
|
async def delete_pattern_template(
|
||||||
|
template_id: str,
|
||||||
|
_auth: AuthRequired,
|
||||||
|
store: PatternTemplateStore = Depends(get_pattern_template_store),
|
||||||
|
target_store: PictureTargetStore = Depends(get_picture_target_store),
|
||||||
|
):
|
||||||
|
"""Delete a pattern template."""
|
||||||
|
try:
|
||||||
|
if store.is_referenced_by(template_id, target_store):
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=409,
|
||||||
|
detail="Cannot delete pattern template: it is referenced by one or more key colors targets. "
|
||||||
|
"Please reassign those targets before deleting.",
|
||||||
|
)
|
||||||
|
store.delete_template(template_id)
|
||||||
|
except HTTPException:
|
||||||
|
raise
|
||||||
|
except ValueError as e:
|
||||||
|
raise HTTPException(status_code=400, detail=str(e))
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to delete pattern template: {e}")
|
||||||
|
raise HTTPException(status_code=500, detail=str(e))
|
||||||
@@ -365,26 +365,33 @@ async def test_picture_source(
|
|||||||
)
|
)
|
||||||
stream.initialize()
|
stream.initialize()
|
||||||
|
|
||||||
logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}")
|
|
||||||
|
|
||||||
frame_count = 0
|
frame_count = 0
|
||||||
total_capture_time = 0.0
|
total_capture_time = 0.0
|
||||||
last_frame = None
|
last_frame = None
|
||||||
|
|
||||||
start_time = time.perf_counter()
|
start_time = time.perf_counter()
|
||||||
end_time = start_time + test_request.capture_duration
|
|
||||||
|
|
||||||
while time.perf_counter() < end_time:
|
if test_request.capture_duration == 0:
|
||||||
|
# Single frame capture
|
||||||
|
logger.info(f"Capturing single frame for {stream_id}")
|
||||||
capture_start = time.perf_counter()
|
capture_start = time.perf_counter()
|
||||||
screen_capture = stream.capture_frame()
|
screen_capture = stream.capture_frame()
|
||||||
capture_elapsed = time.perf_counter() - capture_start
|
capture_elapsed = time.perf_counter() - capture_start
|
||||||
|
if screen_capture is not None:
|
||||||
if screen_capture is None:
|
total_capture_time = capture_elapsed
|
||||||
continue
|
frame_count = 1
|
||||||
|
last_frame = screen_capture
|
||||||
total_capture_time += capture_elapsed
|
else:
|
||||||
frame_count += 1
|
logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}")
|
||||||
last_frame = screen_capture
|
end_time = start_time + test_request.capture_duration
|
||||||
|
while time.perf_counter() < end_time:
|
||||||
|
capture_start = time.perf_counter()
|
||||||
|
screen_capture = stream.capture_frame()
|
||||||
|
capture_elapsed = time.perf_counter() - capture_start
|
||||||
|
if screen_capture is None:
|
||||||
|
continue
|
||||||
|
total_capture_time += capture_elapsed
|
||||||
|
frame_count += 1
|
||||||
|
last_frame = screen_capture
|
||||||
|
|
||||||
actual_duration = time.perf_counter() - start_time
|
actual_duration = time.perf_counter() - start_time
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ from wled_controller.api.dependencies import (
|
|||||||
)
|
)
|
||||||
from wled_controller.api.schemas.picture_targets import (
|
from wled_controller.api.schemas.picture_targets import (
|
||||||
ExtractedColorResponse,
|
ExtractedColorResponse,
|
||||||
KeyColorRectangleSchema,
|
|
||||||
KeyColorsResponse,
|
KeyColorsResponse,
|
||||||
KeyColorsSettingsSchema,
|
KeyColorsSettingsSchema,
|
||||||
PictureTargetCreate,
|
PictureTargetCreate,
|
||||||
@@ -28,7 +27,6 @@ from wled_controller.core.processor_manager import ProcessorManager, ProcessingS
|
|||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
||||||
from wled_controller.storage.key_colors_picture_target import (
|
from wled_controller.storage.key_colors_picture_target import (
|
||||||
KeyColorRectangle,
|
|
||||||
KeyColorsSettings,
|
KeyColorsSettings,
|
||||||
KeyColorsPictureTarget,
|
KeyColorsPictureTarget,
|
||||||
)
|
)
|
||||||
@@ -84,10 +82,7 @@ def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSche
|
|||||||
fps=settings.fps,
|
fps=settings.fps,
|
||||||
interpolation_mode=settings.interpolation_mode,
|
interpolation_mode=settings.interpolation_mode,
|
||||||
smoothing=settings.smoothing,
|
smoothing=settings.smoothing,
|
||||||
rectangles=[
|
pattern_template_id=settings.pattern_template_id,
|
||||||
KeyColorRectangleSchema(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
|
|
||||||
for r in settings.rectangles
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -97,10 +92,7 @@ def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings
|
|||||||
fps=schema.fps,
|
fps=schema.fps,
|
||||||
interpolation_mode=schema.interpolation_mode,
|
interpolation_mode=schema.interpolation_mode,
|
||||||
smoothing=schema.smoothing,
|
smoothing=schema.smoothing,
|
||||||
rectangles=[
|
pattern_template_id=schema.pattern_template_id,
|
||||||
KeyColorRectangle(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
|
|
||||||
for r in schema.rectangles
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -56,6 +56,12 @@ from .postprocessing import (
|
|||||||
PostprocessingTemplateUpdate,
|
PostprocessingTemplateUpdate,
|
||||||
PPTemplateTestRequest,
|
PPTemplateTestRequest,
|
||||||
)
|
)
|
||||||
|
from .pattern_templates import (
|
||||||
|
PatternTemplateCreate,
|
||||||
|
PatternTemplateListResponse,
|
||||||
|
PatternTemplateResponse,
|
||||||
|
PatternTemplateUpdate,
|
||||||
|
)
|
||||||
from .picture_sources import (
|
from .picture_sources import (
|
||||||
ImageValidateRequest,
|
ImageValidateRequest,
|
||||||
ImageValidateResponse,
|
ImageValidateResponse,
|
||||||
@@ -109,6 +115,10 @@ __all__ = [
|
|||||||
"PostprocessingTemplateResponse",
|
"PostprocessingTemplateResponse",
|
||||||
"PostprocessingTemplateUpdate",
|
"PostprocessingTemplateUpdate",
|
||||||
"PPTemplateTestRequest",
|
"PPTemplateTestRequest",
|
||||||
|
"PatternTemplateCreate",
|
||||||
|
"PatternTemplateListResponse",
|
||||||
|
"PatternTemplateResponse",
|
||||||
|
"PatternTemplateUpdate",
|
||||||
"ImageValidateRequest",
|
"ImageValidateRequest",
|
||||||
"ImageValidateResponse",
|
"ImageValidateResponse",
|
||||||
"PictureSourceCreate",
|
"PictureSourceCreate",
|
||||||
|
|||||||
42
server/src/wled_controller/api/schemas/pattern_templates.py
Normal file
42
server/src/wled_controller/api/schemas/pattern_templates.py
Normal file
@@ -0,0 +1,42 @@
|
|||||||
|
"""Pydantic schemas for pattern template API."""
|
||||||
|
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field
|
||||||
|
|
||||||
|
from .picture_targets import KeyColorRectangleSchema
|
||||||
|
|
||||||
|
|
||||||
|
class PatternTemplateCreate(BaseModel):
|
||||||
|
"""Request to create a pattern template."""
|
||||||
|
|
||||||
|
name: str = Field(description="Template name", min_length=1, max_length=100)
|
||||||
|
rectangles: List[KeyColorRectangleSchema] = Field(default_factory=list, description="List of named rectangles")
|
||||||
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class PatternTemplateUpdate(BaseModel):
|
||||||
|
"""Request to update a pattern template."""
|
||||||
|
|
||||||
|
name: Optional[str] = Field(None, description="Template name", min_length=1, max_length=100)
|
||||||
|
rectangles: Optional[List[KeyColorRectangleSchema]] = Field(None, description="List of named rectangles")
|
||||||
|
description: Optional[str] = Field(None, description="Template description", max_length=500)
|
||||||
|
|
||||||
|
|
||||||
|
class PatternTemplateResponse(BaseModel):
|
||||||
|
"""Pattern template response."""
|
||||||
|
|
||||||
|
id: str = Field(description="Template ID")
|
||||||
|
name: str = Field(description="Template name")
|
||||||
|
rectangles: List[KeyColorRectangleSchema] = Field(description="List of named rectangles")
|
||||||
|
created_at: datetime = Field(description="Creation timestamp")
|
||||||
|
updated_at: datetime = Field(description="Last update timestamp")
|
||||||
|
description: Optional[str] = Field(None, description="Template description")
|
||||||
|
|
||||||
|
|
||||||
|
class PatternTemplateListResponse(BaseModel):
|
||||||
|
"""List of pattern templates."""
|
||||||
|
|
||||||
|
templates: List[PatternTemplateResponse] = Field(description="List of pattern templates")
|
||||||
|
count: int = Field(description="Number of templates")
|
||||||
@@ -60,7 +60,7 @@ class PictureSourceListResponse(BaseModel):
|
|||||||
class PictureSourceTestRequest(BaseModel):
|
class PictureSourceTestRequest(BaseModel):
|
||||||
"""Request to test a picture source."""
|
"""Request to test a picture source."""
|
||||||
|
|
||||||
capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds")
|
capture_duration: float = Field(default=5.0, ge=0.0, le=30.0, description="Duration to capture in seconds (0 = single frame)")
|
||||||
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview")
|
border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview")
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -51,7 +51,7 @@ class KeyColorsSettingsSchema(BaseModel):
|
|||||||
fps: int = Field(default=10, description="Extraction rate (1-60)", ge=1, le=60)
|
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)")
|
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)
|
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")
|
pattern_template_id: str = Field(default="", description="Pattern template ID for rectangle layout")
|
||||||
|
|
||||||
|
|
||||||
class ExtractedColorResponse(BaseModel):
|
class ExtractedColorResponse(BaseModel):
|
||||||
|
|||||||
@@ -58,6 +58,7 @@ class StorageConfig(BaseSettings):
|
|||||||
postprocessing_templates_file: str = "data/postprocessing_templates.json"
|
postprocessing_templates_file: str = "data/postprocessing_templates.json"
|
||||||
picture_sources_file: str = "data/picture_sources.json"
|
picture_sources_file: str = "data/picture_sources.json"
|
||||||
picture_targets_file: str = "data/picture_targets.json"
|
picture_targets_file: str = "data/picture_targets.json"
|
||||||
|
pattern_templates_file: str = "data/pattern_templates.json"
|
||||||
|
|
||||||
|
|
||||||
class LoggingConfig(BaseSettings):
|
class LoggingConfig(BaseSettings):
|
||||||
|
|||||||
@@ -155,7 +155,7 @@ class ProcessorManager:
|
|||||||
Targets are registered for processing (streaming sources to devices).
|
Targets are registered for processing (streaming sources to devices).
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None):
|
def __init__(self, picture_source_store=None, capture_template_store=None, pp_template_store=None, pattern_template_store=None):
|
||||||
"""Initialize processor manager."""
|
"""Initialize processor manager."""
|
||||||
self._devices: Dict[str, DeviceState] = {}
|
self._devices: Dict[str, DeviceState] = {}
|
||||||
self._targets: Dict[str, TargetState] = {}
|
self._targets: Dict[str, TargetState] = {}
|
||||||
@@ -165,6 +165,7 @@ class ProcessorManager:
|
|||||||
self._picture_source_store = picture_source_store
|
self._picture_source_store = picture_source_store
|
||||||
self._capture_template_store = capture_template_store
|
self._capture_template_store = capture_template_store
|
||||||
self._pp_template_store = pp_template_store
|
self._pp_template_store = pp_template_store
|
||||||
|
self._pattern_template_store = pattern_template_store
|
||||||
self._live_stream_manager = LiveStreamManager(
|
self._live_stream_manager = LiveStreamManager(
|
||||||
picture_source_store, capture_template_store, pp_template_store
|
picture_source_store, capture_template_store, pp_template_store
|
||||||
)
|
)
|
||||||
@@ -1054,8 +1055,19 @@ class ProcessorManager:
|
|||||||
if not state.picture_source_id:
|
if not state.picture_source_id:
|
||||||
raise ValueError(f"KC target {target_id} has no picture source assigned")
|
raise ValueError(f"KC target {target_id} has no picture source assigned")
|
||||||
|
|
||||||
if not state.settings.rectangles:
|
if not state.settings.pattern_template_id:
|
||||||
raise ValueError(f"KC target {target_id} has no rectangles defined")
|
raise ValueError(f"KC target {target_id} has no pattern template assigned")
|
||||||
|
|
||||||
|
# Resolve pattern template to get rectangles
|
||||||
|
try:
|
||||||
|
pattern_template = self._pattern_template_store.get_template(state.settings.pattern_template_id)
|
||||||
|
except (ValueError, AttributeError):
|
||||||
|
raise ValueError(f"Pattern template {state.settings.pattern_template_id} not found")
|
||||||
|
|
||||||
|
if not pattern_template.rectangles:
|
||||||
|
raise ValueError(f"Pattern template {state.settings.pattern_template_id} has no rectangles")
|
||||||
|
|
||||||
|
state._resolved_rectangles = pattern_template.rectangles
|
||||||
|
|
||||||
# Acquire live stream
|
# Acquire live stream
|
||||||
try:
|
try:
|
||||||
@@ -1133,9 +1145,11 @@ class ProcessorManager:
|
|||||||
frame_time = 1.0 / target_fps
|
frame_time = 1.0 / target_fps
|
||||||
fps_samples: List[float] = []
|
fps_samples: List[float] = []
|
||||||
|
|
||||||
|
rectangles = state._resolved_rectangles
|
||||||
|
|
||||||
logger.info(
|
logger.info(
|
||||||
f"KC processing loop started for target {target_id} "
|
f"KC processing loop started for target {target_id} "
|
||||||
f"(fps={target_fps}, rects={len(settings.rectangles)})"
|
f"(fps={target_fps}, rects={len(rectangles)})"
|
||||||
)
|
)
|
||||||
|
|
||||||
try:
|
try:
|
||||||
@@ -1152,7 +1166,7 @@ class ProcessorManager:
|
|||||||
h, w = img.shape[:2]
|
h, w = img.shape[:2]
|
||||||
|
|
||||||
colors: Dict[str, Tuple[int, int, int]] = {}
|
colors: Dict[str, Tuple[int, int, int]] = {}
|
||||||
for rect in settings.rectangles:
|
for rect in rectangles:
|
||||||
# Convert relative coords to pixel coords
|
# Convert relative coords to pixel coords
|
||||||
px_x = max(0, int(rect.x * w))
|
px_x = max(0, int(rect.x * w))
|
||||||
px_y = max(0, int(rect.y * h))
|
px_y = max(0, int(rect.y * h))
|
||||||
|
|||||||
@@ -17,6 +17,7 @@ from wled_controller.core.processor_manager import ProcessorManager, ProcessingS
|
|||||||
from wled_controller.storage import DeviceStore
|
from wled_controller.storage import DeviceStore
|
||||||
from wled_controller.storage.template_store import TemplateStore
|
from wled_controller.storage.template_store import TemplateStore
|
||||||
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
|
||||||
|
from wled_controller.storage.pattern_template_store import PatternTemplateStore
|
||||||
from wled_controller.storage.picture_source_store import PictureSourceStore
|
from wled_controller.storage.picture_source_store import PictureSourceStore
|
||||||
from wled_controller.storage.picture_target_store import PictureTargetStore
|
from wled_controller.storage.picture_target_store import PictureTargetStore
|
||||||
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
from wled_controller.storage.wled_picture_target import WledPictureTarget
|
||||||
@@ -36,11 +37,13 @@ template_store = TemplateStore(config.storage.templates_file)
|
|||||||
pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_templates_file)
|
pp_template_store = PostprocessingTemplateStore(config.storage.postprocessing_templates_file)
|
||||||
picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
|
picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
|
||||||
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
|
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
|
||||||
|
pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
|
||||||
|
|
||||||
processor_manager = ProcessorManager(
|
processor_manager = ProcessorManager(
|
||||||
picture_source_store=picture_source_store,
|
picture_source_store=picture_source_store,
|
||||||
capture_template_store=template_store,
|
capture_template_store=template_store,
|
||||||
pp_template_store=pp_template_store,
|
pp_template_store=pp_template_store,
|
||||||
|
pattern_template_store=pattern_template_store,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
@@ -130,13 +133,14 @@ async def lifespan(app: FastAPI):
|
|||||||
logger.info(f"Authorized clients: {client_labels}")
|
logger.info(f"Authorized clients: {client_labels}")
|
||||||
logger.info("All API requests require valid Bearer token authentication")
|
logger.info("All API requests require valid Bearer token authentication")
|
||||||
|
|
||||||
# Run migration from legacy device settings to picture targets
|
# Run migrations
|
||||||
_migrate_devices_to_targets()
|
_migrate_devices_to_targets()
|
||||||
|
|
||||||
# Initialize API dependencies
|
# Initialize API dependencies
|
||||||
init_dependencies(
|
init_dependencies(
|
||||||
device_store, template_store, processor_manager,
|
device_store, template_store, processor_manager,
|
||||||
pp_template_store=pp_template_store,
|
pp_template_store=pp_template_store,
|
||||||
|
pattern_template_store=pattern_template_store,
|
||||||
picture_source_store=picture_source_store,
|
picture_source_store=picture_source_store,
|
||||||
picture_target_store=picture_target_store,
|
picture_target_store=picture_target_store,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -3961,11 +3961,12 @@ async function loadTargetsTab() {
|
|||||||
if (!container) return;
|
if (!container) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Fetch devices, targets, and sources in parallel
|
// Fetch devices, targets, sources, and pattern templates in parallel
|
||||||
const [devicesResp, targetsResp, sourcesResp] = await Promise.all([
|
const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
|
||||||
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
|
||||||
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
|
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
|
||||||
fetchWithAuth('/picture-sources').catch(() => null),
|
fetchWithAuth('/picture-sources').catch(() => null),
|
||||||
|
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (devicesResp.status === 401 || targetsResp.status === 401) {
|
if (devicesResp.status === 401 || targetsResp.status === 401) {
|
||||||
@@ -3985,6 +3986,14 @@ async function loadTargetsTab() {
|
|||||||
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
|
(srcData.streams || []).forEach(s => { sourceMap[s.id] = s; });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let patternTemplates = [];
|
||||||
|
let patternTemplateMap = {};
|
||||||
|
if (patResp && patResp.ok) {
|
||||||
|
const patData = await patResp.json();
|
||||||
|
patternTemplates = patData.templates || [];
|
||||||
|
patternTemplates.forEach(pt => { patternTemplateMap[pt.id] = pt; });
|
||||||
|
}
|
||||||
|
|
||||||
// Fetch state for each device
|
// Fetch state for each device
|
||||||
const devicesWithState = await Promise.all(
|
const devicesWithState = await Promise.all(
|
||||||
devices.map(async (device) => {
|
devices.map(async (device) => {
|
||||||
@@ -4033,7 +4042,7 @@ async function loadTargetsTab() {
|
|||||||
|
|
||||||
const subTabs = [
|
const subTabs = [
|
||||||
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
|
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
|
||||||
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length },
|
{ key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
|
||||||
];
|
];
|
||||||
|
|
||||||
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
|
const tabBar = `<div class="stream-tab-bar">${subTabs.map(tab =>
|
||||||
@@ -4069,12 +4078,21 @@ async function loadTargetsTab() {
|
|||||||
<div class="subtab-section">
|
<div class="subtab-section">
|
||||||
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
|
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
|
||||||
<div class="devices-grid">
|
<div class="devices-grid">
|
||||||
${kcTargets.map(target => createKCTargetCard(target, sourceMap)).join('')}
|
${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
|
||||||
<div class="template-card add-template-card" onclick="showKCEditor()">
|
<div class="template-card add-template-card" onclick="showKCEditor()">
|
||||||
<div class="add-template-icon">+</div>
|
<div class="add-template-icon">+</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div class="subtab-section">
|
||||||
|
<h3 class="subtab-section-header">${t('targets.section.pattern_templates')}</h3>
|
||||||
|
<div class="templates-grid">
|
||||||
|
${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')}
|
||||||
|
<div class="template-card add-template-card" onclick="showPatternTemplateEditor()">
|
||||||
|
<div class="add-template-icon">+</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>`;
|
</div>`;
|
||||||
|
|
||||||
container.innerHTML = tabBar + wledPanel + kcPanel;
|
container.innerHTML = tabBar + wledPanel + kcPanel;
|
||||||
@@ -4244,7 +4262,7 @@ async function deleteTarget(targetId) {
|
|||||||
|
|
||||||
// ===== KEY COLORS TARGET CARD =====
|
// ===== KEY COLORS TARGET CARD =====
|
||||||
|
|
||||||
function createKCTargetCard(target, sourceMap) {
|
function createKCTargetCard(target, sourceMap, patternTemplateMap) {
|
||||||
const state = target.state || {};
|
const state = target.state || {};
|
||||||
const metrics = target.metrics || {};
|
const metrics = target.metrics || {};
|
||||||
const kcSettings = target.key_colors_settings || {};
|
const kcSettings = target.key_colors_settings || {};
|
||||||
@@ -4253,7 +4271,9 @@ function createKCTargetCard(target, sourceMap) {
|
|||||||
|
|
||||||
const source = sourceMap[target.picture_source_id];
|
const source = sourceMap[target.picture_source_id];
|
||||||
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
|
const sourceName = source ? source.name : (target.picture_source_id || 'No source');
|
||||||
const rectCount = (kcSettings.rectangles || []).length;
|
const patTmpl = patternTemplateMap[kcSettings.pattern_template_id];
|
||||||
|
const patternName = patTmpl ? patTmpl.name : 'No pattern';
|
||||||
|
const rectCount = patTmpl ? (patTmpl.rectangles || []).length : 0;
|
||||||
|
|
||||||
// Render initial color swatches from pre-fetched REST data
|
// Render initial color swatches from pre-fetched REST data
|
||||||
let swatchesHtml = '';
|
let swatchesHtml = '';
|
||||||
@@ -4281,7 +4301,8 @@ function createKCTargetCard(target, sourceMap) {
|
|||||||
</div>
|
</div>
|
||||||
<div class="stream-card-props">
|
<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.source')}">📺 ${escapeHtml(sourceName)}</span>
|
||||||
<span class="stream-card-prop" title="${t('kc.rectangles')}">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
<span class="stream-card-prop" title="${t('kc.pattern_template')}">📄 ${escapeHtml(patternName)}</span>
|
||||||
|
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="card-content">
|
<div class="card-content">
|
||||||
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
|
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
|
||||||
@@ -4328,73 +4349,33 @@ function createKCTargetCard(target, sourceMap) {
|
|||||||
|
|
||||||
// ===== KEY COLORS EDITOR =====
|
// ===== KEY COLORS EDITOR =====
|
||||||
|
|
||||||
let kcEditorRectangles = [];
|
|
||||||
let kcEditorInitialValues = {};
|
let kcEditorInitialValues = {};
|
||||||
let _kcNameManuallyEdited = false;
|
let _kcNameManuallyEdited = false;
|
||||||
|
|
||||||
function _autoGenerateKCName() {
|
function _autoGenerateKCName() {
|
||||||
if (_kcNameManuallyEdited) return;
|
if (_kcNameManuallyEdited) return;
|
||||||
if (document.getElementById('kc-editor-id').value) return; // editing, not creating
|
if (document.getElementById('kc-editor-id').value) return;
|
||||||
const sourceSelect = document.getElementById('kc-editor-source');
|
const sourceSelect = document.getElementById('kc-editor-source');
|
||||||
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
|
||||||
if (!sourceName) return;
|
if (!sourceName) return;
|
||||||
const rectCount = kcEditorRectangles.length;
|
|
||||||
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
|
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
|
||||||
const modeName = t(`kc.interpolation.${mode}`);
|
const modeName = t(`kc.interpolation.${mode}`);
|
||||||
document.getElementById('kc-editor-name').value = `${sourceName} [${rectCount}](${modeName})`;
|
const patSelect = document.getElementById('kc-editor-pattern-template');
|
||||||
}
|
const patName = patSelect.selectedOptions[0]?.textContent?.trim() || '';
|
||||||
|
document.getElementById('kc-editor-name').value = `${sourceName} · ${patName} (${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) {
|
async function showKCEditor(targetId = null) {
|
||||||
try {
|
try {
|
||||||
// Load sources for dropdown
|
// Load sources and pattern templates in parallel
|
||||||
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
|
const [sourcesResp, patResp] = await Promise.all([
|
||||||
|
fetchWithAuth('/picture-sources').catch(() => null),
|
||||||
|
fetchWithAuth('/pattern-templates').catch(() => null),
|
||||||
|
]);
|
||||||
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
|
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
|
||||||
|
const patTemplates = (patResp && patResp.ok) ? (await patResp.json()).templates || [] : [];
|
||||||
|
|
||||||
// Populate source select (no empty option — source is required for KC targets)
|
// Populate source select
|
||||||
const sourceSelect = document.getElementById('kc-editor-source');
|
const sourceSelect = document.getElementById('kc-editor-source');
|
||||||
sourceSelect.innerHTML = '';
|
sourceSelect.innerHTML = '';
|
||||||
sources.forEach(s => {
|
sources.forEach(s => {
|
||||||
@@ -4406,8 +4387,18 @@ async function showKCEditor(targetId = null) {
|
|||||||
sourceSelect.appendChild(opt);
|
sourceSelect.appendChild(opt);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Populate pattern template select
|
||||||
|
const patSelect = document.getElementById('kc-editor-pattern-template');
|
||||||
|
patSelect.innerHTML = `<option value="">${t('kc.pattern_template.none')}</option>`;
|
||||||
|
patTemplates.forEach(pt => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = pt.id;
|
||||||
|
const rectCount = (pt.rectangles || []).length;
|
||||||
|
opt.textContent = `📄 ${pt.name} (${rectCount} rect${rectCount !== 1 ? 's' : ''})`;
|
||||||
|
patSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
if (targetId) {
|
if (targetId) {
|
||||||
// Editing existing target
|
|
||||||
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
|
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
|
||||||
if (!resp.ok) throw new Error('Failed to load target');
|
if (!resp.ok) throw new Error('Failed to load target');
|
||||||
const target = await resp.json();
|
const target = await resp.json();
|
||||||
@@ -4421,11 +4412,9 @@ async function showKCEditor(targetId = null) {
|
|||||||
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
|
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 = kcSettings.smoothing ?? 0.3;
|
||||||
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
|
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
|
||||||
|
patSelect.value = kcSettings.pattern_template_id || '';
|
||||||
document.getElementById('kc-editor-title').textContent = t('kc.edit');
|
document.getElementById('kc-editor-title').textContent = t('kc.edit');
|
||||||
|
|
||||||
kcEditorRectangles = (kcSettings.rectangles || []).map(r => ({ ...r }));
|
|
||||||
} else {
|
} else {
|
||||||
// Creating new target
|
|
||||||
document.getElementById('kc-editor-id').value = '';
|
document.getElementById('kc-editor-id').value = '';
|
||||||
document.getElementById('kc-editor-name').value = '';
|
document.getElementById('kc-editor-name').value = '';
|
||||||
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
|
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
|
||||||
@@ -4434,20 +4423,16 @@ async function showKCEditor(targetId = null) {
|
|||||||
document.getElementById('kc-editor-interpolation').value = 'average';
|
document.getElementById('kc-editor-interpolation').value = 'average';
|
||||||
document.getElementById('kc-editor-smoothing').value = 0.3;
|
document.getElementById('kc-editor-smoothing').value = 0.3;
|
||||||
document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
|
document.getElementById('kc-editor-smoothing-value').textContent = '0.3';
|
||||||
|
if (patTemplates.length > 0) patSelect.value = patTemplates[0].id;
|
||||||
document.getElementById('kc-editor-title').textContent = t('kc.add');
|
document.getElementById('kc-editor-title').textContent = t('kc.add');
|
||||||
|
|
||||||
kcEditorRectangles = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
renderKCRectangles();
|
// Auto-name
|
||||||
|
_kcNameManuallyEdited = !!targetId;
|
||||||
// Auto-name: reset flag and wire listeners
|
|
||||||
_kcNameManuallyEdited = !!targetId; // treat edit mode as manually edited
|
|
||||||
document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; };
|
document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; };
|
||||||
sourceSelect.onchange = () => _autoGenerateKCName();
|
sourceSelect.onchange = () => _autoGenerateKCName();
|
||||||
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
|
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
|
||||||
|
patSelect.onchange = () => _autoGenerateKCName();
|
||||||
// Trigger auto-name after dropdowns are populated (create mode only)
|
|
||||||
if (!targetId) _autoGenerateKCName();
|
if (!targetId) _autoGenerateKCName();
|
||||||
|
|
||||||
kcEditorInitialValues = {
|
kcEditorInitialValues = {
|
||||||
@@ -4456,7 +4441,7 @@ async function showKCEditor(targetId = null) {
|
|||||||
fps: document.getElementById('kc-editor-fps').value,
|
fps: document.getElementById('kc-editor-fps').value,
|
||||||
interpolation: document.getElementById('kc-editor-interpolation').value,
|
interpolation: document.getElementById('kc-editor-interpolation').value,
|
||||||
smoothing: document.getElementById('kc-editor-smoothing').value,
|
smoothing: document.getElementById('kc-editor-smoothing').value,
|
||||||
rectangles: JSON.stringify(kcEditorRectangles),
|
patternTemplateId: patSelect.value,
|
||||||
};
|
};
|
||||||
|
|
||||||
const modal = document.getElementById('kc-editor-modal');
|
const modal = document.getElementById('kc-editor-modal');
|
||||||
@@ -4479,7 +4464,7 @@ function isKCEditorDirty() {
|
|||||||
document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps ||
|
document.getElementById('kc-editor-fps').value !== kcEditorInitialValues.fps ||
|
||||||
document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation ||
|
document.getElementById('kc-editor-interpolation').value !== kcEditorInitialValues.interpolation ||
|
||||||
document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing ||
|
document.getElementById('kc-editor-smoothing').value !== kcEditorInitialValues.smoothing ||
|
||||||
JSON.stringify(kcEditorRectangles) !== kcEditorInitialValues.rectangles
|
document.getElementById('kc-editor-pattern-template').value !== kcEditorInitialValues.patternTemplateId
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4496,7 +4481,6 @@ function forceCloseKCEditorModal() {
|
|||||||
document.getElementById('kc-editor-error').style.display = 'none';
|
document.getElementById('kc-editor-error').style.display = 'none';
|
||||||
unlockBody();
|
unlockBody();
|
||||||
kcEditorInitialValues = {};
|
kcEditorInitialValues = {};
|
||||||
kcEditorRectangles = [];
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function saveKCEditor() {
|
async function saveKCEditor() {
|
||||||
@@ -4506,6 +4490,7 @@ async function saveKCEditor() {
|
|||||||
const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
|
const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
|
||||||
const interpolation = document.getElementById('kc-editor-interpolation').value;
|
const interpolation = document.getElementById('kc-editor-interpolation').value;
|
||||||
const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
|
const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
|
||||||
|
const patternTemplateId = document.getElementById('kc-editor-pattern-template').value;
|
||||||
const errorEl = document.getElementById('kc-editor-error');
|
const errorEl = document.getElementById('kc-editor-error');
|
||||||
|
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -4514,8 +4499,8 @@ async function saveKCEditor() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (kcEditorRectangles.length === 0) {
|
if (!patternTemplateId) {
|
||||||
errorEl.textContent = t('kc.error.no_rectangles');
|
errorEl.textContent = t('kc.error.no_pattern');
|
||||||
errorEl.style.display = 'block';
|
errorEl.style.display = 'block';
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4527,13 +4512,7 @@ async function saveKCEditor() {
|
|||||||
fps,
|
fps,
|
||||||
interpolation_mode: interpolation,
|
interpolation_mode: interpolation,
|
||||||
smoothing,
|
smoothing,
|
||||||
rectangles: kcEditorRectangles.map(r => ({
|
pattern_template_id: patternTemplateId,
|
||||||
name: r.name,
|
|
||||||
x: r.x,
|
|
||||||
y: r.y,
|
|
||||||
width: r.width,
|
|
||||||
height: r.height,
|
|
||||||
})),
|
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -4663,3 +4642,545 @@ function updateKCColorSwatches(targetId, colors) {
|
|||||||
</div>
|
</div>
|
||||||
`).join('');
|
`).join('');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ===== PATTERN TEMPLATES =====
|
||||||
|
|
||||||
|
function createPatternTemplateCard(pt) {
|
||||||
|
const rectCount = (pt.rectangles || []).length;
|
||||||
|
const desc = pt.description ? `<div class="template-description">${escapeHtml(pt.description)}</div>` : '';
|
||||||
|
return `
|
||||||
|
<div class="template-card" data-pattern-template-id="${pt.id}">
|
||||||
|
<button class="card-remove-btn" onclick="deletePatternTemplate('${pt.id}')" title="${t('common.delete')}">✕</button>
|
||||||
|
<div class="template-card-header">
|
||||||
|
<span class="template-name">📄 ${escapeHtml(pt.name)}</span>
|
||||||
|
</div>
|
||||||
|
${desc}
|
||||||
|
<div class="stream-card-props">
|
||||||
|
<span class="stream-card-prop">▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
|
||||||
|
</div>
|
||||||
|
<div class="template-card-actions">
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="showPatternTemplateEditor('${pt.id}')" title="${t('common.edit')}">✏️</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Pattern Template Editor state -----
|
||||||
|
let patternEditorRects = [];
|
||||||
|
let patternEditorSelectedIdx = -1;
|
||||||
|
let patternEditorBgImage = null;
|
||||||
|
let patternEditorInitialValues = {};
|
||||||
|
let patternCanvasDragMode = null;
|
||||||
|
let patternCanvasDragStart = null;
|
||||||
|
let patternCanvasDragOrigRect = null;
|
||||||
|
|
||||||
|
const PATTERN_RECT_COLORS = [
|
||||||
|
'rgba(76,175,80,0.35)', 'rgba(33,150,243,0.35)', 'rgba(255,152,0,0.35)',
|
||||||
|
'rgba(156,39,176,0.35)', 'rgba(0,188,212,0.35)', 'rgba(244,67,54,0.35)',
|
||||||
|
'rgba(255,235,59,0.35)', 'rgba(121,85,72,0.35)',
|
||||||
|
];
|
||||||
|
const PATTERN_RECT_BORDERS = [
|
||||||
|
'#4CAF50', '#2196F3', '#FF9800', '#9C27B0', '#00BCD4', '#F44336', '#FFEB3B', '#795548',
|
||||||
|
];
|
||||||
|
|
||||||
|
async function showPatternTemplateEditor(templateId = null) {
|
||||||
|
try {
|
||||||
|
// Load sources for background capture
|
||||||
|
const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
|
||||||
|
const sources = (sourcesResp && sourcesResp.ok) ? (await sourcesResp.json()).streams || [] : [];
|
||||||
|
|
||||||
|
const bgSelect = document.getElementById('pattern-bg-source');
|
||||||
|
bgSelect.innerHTML = '';
|
||||||
|
sources.forEach(s => {
|
||||||
|
const opt = document.createElement('option');
|
||||||
|
opt.value = s.id;
|
||||||
|
const typeIcon = s.stream_type === 'raw' ? '🖥️' : s.stream_type === 'static_image' ? '🖼️' : '🎨';
|
||||||
|
opt.textContent = `${typeIcon} ${s.name}`;
|
||||||
|
bgSelect.appendChild(opt);
|
||||||
|
});
|
||||||
|
|
||||||
|
patternEditorBgImage = null;
|
||||||
|
patternEditorSelectedIdx = -1;
|
||||||
|
patternCanvasDragMode = null;
|
||||||
|
|
||||||
|
if (templateId) {
|
||||||
|
const resp = await fetch(`${API_BASE}/pattern-templates/${templateId}`, { headers: getHeaders() });
|
||||||
|
if (!resp.ok) throw new Error('Failed to load pattern template');
|
||||||
|
const tmpl = await resp.json();
|
||||||
|
|
||||||
|
document.getElementById('pattern-template-id').value = tmpl.id;
|
||||||
|
document.getElementById('pattern-template-name').value = tmpl.name;
|
||||||
|
document.getElementById('pattern-template-description').value = tmpl.description || '';
|
||||||
|
document.getElementById('pattern-template-title').textContent = t('pattern.edit');
|
||||||
|
patternEditorRects = (tmpl.rectangles || []).map(r => ({ ...r }));
|
||||||
|
} else {
|
||||||
|
document.getElementById('pattern-template-id').value = '';
|
||||||
|
document.getElementById('pattern-template-name').value = '';
|
||||||
|
document.getElementById('pattern-template-description').value = '';
|
||||||
|
document.getElementById('pattern-template-title').textContent = t('pattern.add');
|
||||||
|
patternEditorRects = [];
|
||||||
|
}
|
||||||
|
|
||||||
|
patternEditorInitialValues = {
|
||||||
|
name: document.getElementById('pattern-template-name').value,
|
||||||
|
description: document.getElementById('pattern-template-description').value,
|
||||||
|
rectangles: JSON.stringify(patternEditorRects),
|
||||||
|
};
|
||||||
|
|
||||||
|
renderPatternRectList();
|
||||||
|
renderPatternCanvas();
|
||||||
|
_attachPatternCanvasEvents();
|
||||||
|
|
||||||
|
const modal = document.getElementById('pattern-template-modal');
|
||||||
|
modal.style.display = 'flex';
|
||||||
|
lockBody();
|
||||||
|
setupBackdropClose(modal, closePatternTemplateModal);
|
||||||
|
|
||||||
|
document.getElementById('pattern-template-error').style.display = 'none';
|
||||||
|
setTimeout(() => document.getElementById('pattern-template-name').focus(), 100);
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to open pattern template editor:', error);
|
||||||
|
showToast('Failed to open pattern template editor', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function isPatternEditorDirty() {
|
||||||
|
return (
|
||||||
|
document.getElementById('pattern-template-name').value !== patternEditorInitialValues.name ||
|
||||||
|
document.getElementById('pattern-template-description').value !== patternEditorInitialValues.description ||
|
||||||
|
JSON.stringify(patternEditorRects) !== patternEditorInitialValues.rectangles
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function closePatternTemplateModal() {
|
||||||
|
if (isPatternEditorDirty()) {
|
||||||
|
const confirmed = await showConfirm(t('modal.discard_changes'));
|
||||||
|
if (!confirmed) return;
|
||||||
|
}
|
||||||
|
forceClosePatternTemplateModal();
|
||||||
|
}
|
||||||
|
|
||||||
|
function forceClosePatternTemplateModal() {
|
||||||
|
document.getElementById('pattern-template-modal').style.display = 'none';
|
||||||
|
document.getElementById('pattern-template-error').style.display = 'none';
|
||||||
|
unlockBody();
|
||||||
|
patternEditorRects = [];
|
||||||
|
patternEditorSelectedIdx = -1;
|
||||||
|
patternEditorBgImage = null;
|
||||||
|
patternEditorInitialValues = {};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function savePatternTemplate() {
|
||||||
|
const templateId = document.getElementById('pattern-template-id').value;
|
||||||
|
const name = document.getElementById('pattern-template-name').value.trim();
|
||||||
|
const description = document.getElementById('pattern-template-description').value.trim();
|
||||||
|
const errorEl = document.getElementById('pattern-template-error');
|
||||||
|
|
||||||
|
if (!name) {
|
||||||
|
errorEl.textContent = t('pattern.error.required');
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const payload = {
|
||||||
|
name,
|
||||||
|
rectangles: patternEditorRects.map(r => ({
|
||||||
|
name: r.name, x: r.x, y: r.y, width: r.width, height: r.height,
|
||||||
|
})),
|
||||||
|
description: description || null,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
let response;
|
||||||
|
if (templateId) {
|
||||||
|
response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, {
|
||||||
|
method: 'PUT', headers: getHeaders(), body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
response = await fetch(`${API_BASE}/pattern-templates`, {
|
||||||
|
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(templateId ? t('pattern.updated') : t('pattern.created'), 'success');
|
||||||
|
forceClosePatternTemplateModal();
|
||||||
|
await loadTargets();
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving pattern template:', error);
|
||||||
|
errorEl.textContent = error.message;
|
||||||
|
errorEl.style.display = 'block';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function deletePatternTemplate(templateId) {
|
||||||
|
const confirmed = await showConfirm(t('pattern.delete.confirm'));
|
||||||
|
if (!confirmed) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const response = await fetch(`${API_BASE}/pattern-templates/${templateId}`, {
|
||||||
|
method: 'DELETE', headers: getHeaders(),
|
||||||
|
});
|
||||||
|
if (response.status === 401) { handle401Error(); return; }
|
||||||
|
if (response.status === 409) {
|
||||||
|
showToast(t('pattern.delete.referenced'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (response.ok) {
|
||||||
|
showToast(t('pattern.deleted'), 'success');
|
||||||
|
loadTargets();
|
||||||
|
} else {
|
||||||
|
const error = await response.json();
|
||||||
|
showToast(`Failed to delete: ${error.detail}`, 'error');
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
showToast('Failed to delete pattern template', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Pattern rect list (precise coordinate inputs) -----
|
||||||
|
|
||||||
|
function renderPatternRectList() {
|
||||||
|
const container = document.getElementById('pattern-rect-list');
|
||||||
|
if (!container) return;
|
||||||
|
|
||||||
|
if (patternEditorRects.length === 0) {
|
||||||
|
container.innerHTML = `<div class="kc-rect-empty">${t('pattern.rect.empty')}</div>`;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
container.innerHTML = patternEditorRects.map((rect, i) => `
|
||||||
|
<div class="pattern-rect-row${i === patternEditorSelectedIdx ? ' selected' : ''}" onclick="selectPatternRect(${i})">
|
||||||
|
<input type="text" value="${escapeHtml(rect.name)}" placeholder="${t('pattern.rect.name')}" onchange="updatePatternRect(${i}, 'name', this.value)" onclick="event.stopPropagation()">
|
||||||
|
<input type="number" value="${rect.x.toFixed(2)}" min="0" max="1" step="0.01" onchange="updatePatternRect(${i}, 'x', parseFloat(this.value)||0)" onclick="event.stopPropagation()">
|
||||||
|
<input type="number" value="${rect.y.toFixed(2)}" min="0" max="1" step="0.01" onchange="updatePatternRect(${i}, 'y', parseFloat(this.value)||0)" onclick="event.stopPropagation()">
|
||||||
|
<input type="number" value="${rect.width.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'width', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
|
||||||
|
<input type="number" value="${rect.height.toFixed(2)}" min="0.01" max="1" step="0.01" onchange="updatePatternRect(${i}, 'height', parseFloat(this.value)||0.01)" onclick="event.stopPropagation()">
|
||||||
|
<button type="button" class="pattern-rect-remove-btn" onclick="event.stopPropagation(); removePatternRect(${i})" title="${t('pattern.rect.remove')}">✕</button>
|
||||||
|
</div>
|
||||||
|
`).join('');
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectPatternRect(index) {
|
||||||
|
patternEditorSelectedIdx = (patternEditorSelectedIdx === index) ? -1 : index;
|
||||||
|
renderPatternRectList();
|
||||||
|
renderPatternCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function updatePatternRect(index, field, value) {
|
||||||
|
if (index < 0 || index >= patternEditorRects.length) return;
|
||||||
|
patternEditorRects[index][field] = value;
|
||||||
|
// Clamp coordinates
|
||||||
|
if (field !== 'name') {
|
||||||
|
const r = patternEditorRects[index];
|
||||||
|
r.x = Math.max(0, Math.min(1 - r.width, r.x));
|
||||||
|
r.y = Math.max(0, Math.min(1 - r.height, r.y));
|
||||||
|
r.width = Math.max(0.01, Math.min(1, r.width));
|
||||||
|
r.height = Math.max(0.01, Math.min(1, r.height));
|
||||||
|
}
|
||||||
|
renderPatternCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function addPatternRect() {
|
||||||
|
const name = `Zone ${patternEditorRects.length + 1}`;
|
||||||
|
// Place new rect centered, 30% of canvas
|
||||||
|
patternEditorRects.push({ name, x: 0.35, y: 0.35, width: 0.3, height: 0.3 });
|
||||||
|
patternEditorSelectedIdx = patternEditorRects.length - 1;
|
||||||
|
renderPatternRectList();
|
||||||
|
renderPatternCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function deleteSelectedPatternRect() {
|
||||||
|
if (patternEditorSelectedIdx < 0 || patternEditorSelectedIdx >= patternEditorRects.length) return;
|
||||||
|
patternEditorRects.splice(patternEditorSelectedIdx, 1);
|
||||||
|
patternEditorSelectedIdx = -1;
|
||||||
|
renderPatternRectList();
|
||||||
|
renderPatternCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function removePatternRect(index) {
|
||||||
|
patternEditorRects.splice(index, 1);
|
||||||
|
if (patternEditorSelectedIdx === index) patternEditorSelectedIdx = -1;
|
||||||
|
else if (patternEditorSelectedIdx > index) patternEditorSelectedIdx--;
|
||||||
|
renderPatternRectList();
|
||||||
|
renderPatternCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ----- Pattern Canvas Visual Editor -----
|
||||||
|
|
||||||
|
function renderPatternCanvas() {
|
||||||
|
const canvas = document.getElementById('pattern-canvas');
|
||||||
|
if (!canvas) return;
|
||||||
|
const ctx = canvas.getContext('2d');
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
ctx.clearRect(0, 0, w, h);
|
||||||
|
|
||||||
|
// Draw background image or grid
|
||||||
|
if (patternEditorBgImage) {
|
||||||
|
ctx.drawImage(patternEditorBgImage, 0, 0, w, h);
|
||||||
|
} else {
|
||||||
|
// Draw subtle grid — spacing adapts to canvas size
|
||||||
|
ctx.fillStyle = 'rgba(128,128,128,0.05)';
|
||||||
|
ctx.fillRect(0, 0, w, h);
|
||||||
|
ctx.strokeStyle = 'rgba(128,128,128,0.15)';
|
||||||
|
ctx.lineWidth = 1;
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
const gridStep = 80 * dpr; // ~80 CSS pixels between grid lines
|
||||||
|
const colsCount = Math.max(2, Math.round(w / gridStep));
|
||||||
|
const rowsCount = Math.max(2, Math.round(h / gridStep));
|
||||||
|
for (let gx = 0; gx <= colsCount; gx++) {
|
||||||
|
const x = Math.round(gx * w / colsCount) + 0.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(x, 0); ctx.lineTo(x, h); ctx.stroke();
|
||||||
|
}
|
||||||
|
for (let gy = 0; gy <= rowsCount; gy++) {
|
||||||
|
const y = Math.round(gy * h / rowsCount) + 0.5;
|
||||||
|
ctx.beginPath(); ctx.moveTo(0, y); ctx.lineTo(w, y); ctx.stroke();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Draw rectangles
|
||||||
|
patternEditorRects.forEach((rect, i) => {
|
||||||
|
const rx = rect.x * w;
|
||||||
|
const ry = rect.y * h;
|
||||||
|
const rw = rect.width * w;
|
||||||
|
const rh = rect.height * h;
|
||||||
|
const colorIdx = i % PATTERN_RECT_COLORS.length;
|
||||||
|
|
||||||
|
// Fill
|
||||||
|
ctx.fillStyle = PATTERN_RECT_COLORS[colorIdx];
|
||||||
|
ctx.fillRect(rx, ry, rw, rh);
|
||||||
|
|
||||||
|
// Border
|
||||||
|
ctx.strokeStyle = PATTERN_RECT_BORDERS[colorIdx];
|
||||||
|
ctx.lineWidth = (i === patternEditorSelectedIdx) ? 3 : 1.5;
|
||||||
|
ctx.strokeRect(rx, ry, rw, rh);
|
||||||
|
|
||||||
|
// Name label
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.font = '12px sans-serif';
|
||||||
|
ctx.shadowColor = 'rgba(0,0,0,0.7)';
|
||||||
|
ctx.shadowBlur = 3;
|
||||||
|
ctx.fillText(rect.name, rx + 4, ry + 14);
|
||||||
|
ctx.shadowBlur = 0;
|
||||||
|
});
|
||||||
|
|
||||||
|
// Draw resize handles on selected rect
|
||||||
|
if (patternEditorSelectedIdx >= 0 && patternEditorSelectedIdx < patternEditorRects.length) {
|
||||||
|
const rect = patternEditorRects[patternEditorSelectedIdx];
|
||||||
|
const rx = rect.x * w;
|
||||||
|
const ry = rect.y * h;
|
||||||
|
const rw = rect.width * w;
|
||||||
|
const rh = rect.height * h;
|
||||||
|
const hs = 6; // handle size
|
||||||
|
|
||||||
|
ctx.fillStyle = '#fff';
|
||||||
|
ctx.strokeStyle = '#4CAF50';
|
||||||
|
ctx.lineWidth = 1.5;
|
||||||
|
|
||||||
|
const handles = [
|
||||||
|
[rx, ry], [rx + rw / 2, ry], [rx + rw, ry],
|
||||||
|
[rx, ry + rh / 2], [rx + rw, ry + rh / 2],
|
||||||
|
[rx, ry + rh], [rx + rw / 2, ry + rh], [rx + rw, ry + rh],
|
||||||
|
];
|
||||||
|
handles.forEach(([hx, hy]) => {
|
||||||
|
ctx.fillRect(hx - hs / 2, hy - hs / 2, hs, hs);
|
||||||
|
ctx.strokeRect(hx - hs / 2, hy - hs / 2, hs, hs);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _attachPatternCanvasEvents() {
|
||||||
|
const canvas = document.getElementById('pattern-canvas');
|
||||||
|
if (!canvas || canvas._patternEventsAttached) return;
|
||||||
|
canvas._patternEventsAttached = true;
|
||||||
|
|
||||||
|
canvas.addEventListener('mousedown', _patternCanvasMouseDown);
|
||||||
|
canvas.addEventListener('mousemove', _patternCanvasMouseMove);
|
||||||
|
canvas.addEventListener('mouseup', _patternCanvasMouseUp);
|
||||||
|
canvas.addEventListener('mouseleave', _patternCanvasMouseUp);
|
||||||
|
|
||||||
|
// Touch support
|
||||||
|
canvas.addEventListener('touchstart', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
_patternCanvasMouseDown(_touchToMouseEvent(canvas, touch, 'mousedown'));
|
||||||
|
}, { passive: false });
|
||||||
|
canvas.addEventListener('touchmove', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const touch = e.touches[0];
|
||||||
|
_patternCanvasMouseMove(_touchToMouseEvent(canvas, touch, 'mousemove'));
|
||||||
|
}, { passive: false });
|
||||||
|
canvas.addEventListener('touchend', (e) => {
|
||||||
|
_patternCanvasMouseUp(e);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Resize observer — update canvas internal resolution when container is resized
|
||||||
|
const container = canvas.parentElement;
|
||||||
|
if (container && typeof ResizeObserver !== 'undefined') {
|
||||||
|
const ro = new ResizeObserver(() => {
|
||||||
|
const rect = container.getBoundingClientRect();
|
||||||
|
const dpr = window.devicePixelRatio || 1;
|
||||||
|
canvas.width = Math.round(rect.width * dpr);
|
||||||
|
canvas.height = Math.round(rect.height * dpr);
|
||||||
|
renderPatternCanvas();
|
||||||
|
});
|
||||||
|
ro.observe(container);
|
||||||
|
canvas._patternResizeObserver = ro;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function _touchToMouseEvent(canvas, touch, type) {
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
return { type, offsetX: touch.clientX - rect.left, offsetY: touch.clientY - rect.top, preventDefault: () => {} };
|
||||||
|
}
|
||||||
|
|
||||||
|
function _patternCanvasMouseDown(e) {
|
||||||
|
const canvas = document.getElementById('pattern-canvas');
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const scaleX = w / rect.width;
|
||||||
|
const scaleY = h / rect.height;
|
||||||
|
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
|
||||||
|
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
|
// Check resize handles on selected rect first
|
||||||
|
if (patternEditorSelectedIdx >= 0) {
|
||||||
|
const sr = patternEditorRects[patternEditorSelectedIdx];
|
||||||
|
const rx = sr.x * w, ry = sr.y * h, rw = sr.width * w, rh = sr.height * h;
|
||||||
|
const hs = 8;
|
||||||
|
|
||||||
|
const handlePositions = [
|
||||||
|
{ name: 'nw', hx: rx, hy: ry },
|
||||||
|
{ name: 'n', hx: rx + rw / 2, hy: ry },
|
||||||
|
{ name: 'ne', hx: rx + rw, hy: ry },
|
||||||
|
{ name: 'w', hx: rx, hy: ry + rh / 2 },
|
||||||
|
{ name: 'e', hx: rx + rw, hy: ry + rh / 2 },
|
||||||
|
{ name: 'sw', hx: rx, hy: ry + rh },
|
||||||
|
{ name: 's', hx: rx + rw / 2, hy: ry + rh },
|
||||||
|
{ name: 'se', hx: rx + rw, hy: ry + rh },
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const hp of handlePositions) {
|
||||||
|
if (Math.abs(mx - hp.hx) <= hs && Math.abs(my - hp.hy) <= hs) {
|
||||||
|
patternCanvasDragMode = `resize-${hp.name}`;
|
||||||
|
patternCanvasDragStart = { mx, my };
|
||||||
|
patternCanvasDragOrigRect = { ...sr };
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hit-test rect bodies (reverse order for top-most first)
|
||||||
|
for (let i = patternEditorRects.length - 1; i >= 0; i--) {
|
||||||
|
const r = patternEditorRects[i];
|
||||||
|
const rx = r.x * w, ry = r.y * h, rw = r.width * w, rh = r.height * h;
|
||||||
|
if (mx >= rx && mx <= rx + rw && my >= ry && my <= ry + rh) {
|
||||||
|
patternEditorSelectedIdx = i;
|
||||||
|
patternCanvasDragMode = 'move';
|
||||||
|
patternCanvasDragStart = { mx, my };
|
||||||
|
patternCanvasDragOrigRect = { ...r };
|
||||||
|
renderPatternRectList();
|
||||||
|
renderPatternCanvas();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Click on empty space — deselect
|
||||||
|
patternEditorSelectedIdx = -1;
|
||||||
|
patternCanvasDragMode = null;
|
||||||
|
renderPatternRectList();
|
||||||
|
renderPatternCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _patternCanvasMouseMove(e) {
|
||||||
|
if (!patternCanvasDragMode || patternEditorSelectedIdx < 0) return;
|
||||||
|
|
||||||
|
const canvas = document.getElementById('pattern-canvas');
|
||||||
|
const w = canvas.width;
|
||||||
|
const h = canvas.height;
|
||||||
|
const rect = canvas.getBoundingClientRect();
|
||||||
|
const scaleX = w / rect.width;
|
||||||
|
const scaleY = h / rect.height;
|
||||||
|
const mx = (e.offsetX !== undefined ? e.offsetX : e.clientX - rect.left) * scaleX;
|
||||||
|
const my = (e.offsetY !== undefined ? e.offsetY : e.clientY - rect.top) * scaleY;
|
||||||
|
|
||||||
|
const dx = (mx - patternCanvasDragStart.mx) / w;
|
||||||
|
const dy = (my - patternCanvasDragStart.my) / h;
|
||||||
|
const orig = patternCanvasDragOrigRect;
|
||||||
|
const r = patternEditorRects[patternEditorSelectedIdx];
|
||||||
|
|
||||||
|
if (patternCanvasDragMode === 'move') {
|
||||||
|
r.x = Math.max(0, Math.min(1 - r.width, orig.x + dx));
|
||||||
|
r.y = Math.max(0, Math.min(1 - r.height, orig.y + dy));
|
||||||
|
} else if (patternCanvasDragMode.startsWith('resize-')) {
|
||||||
|
const dir = patternCanvasDragMode.replace('resize-', '');
|
||||||
|
let nx = orig.x, ny = orig.y, nw = orig.width, nh = orig.height;
|
||||||
|
|
||||||
|
if (dir.includes('w')) { nx = orig.x + dx; nw = orig.width - dx; }
|
||||||
|
if (dir.includes('e')) { nw = orig.width + dx; }
|
||||||
|
if (dir.includes('n')) { ny = orig.y + dy; nh = orig.height - dy; }
|
||||||
|
if (dir.includes('s')) { nh = orig.height + dy; }
|
||||||
|
|
||||||
|
// Enforce minimums
|
||||||
|
if (nw < 0.02) { nw = 0.02; if (dir.includes('w')) nx = orig.x + orig.width - 0.02; }
|
||||||
|
if (nh < 0.02) { nh = 0.02; if (dir.includes('n')) ny = orig.y + orig.height - 0.02; }
|
||||||
|
|
||||||
|
// Clamp to canvas
|
||||||
|
nx = Math.max(0, Math.min(1 - nw, nx));
|
||||||
|
ny = Math.max(0, Math.min(1 - nh, ny));
|
||||||
|
nw = Math.min(1, nw);
|
||||||
|
nh = Math.min(1, nh);
|
||||||
|
|
||||||
|
r.x = nx; r.y = ny; r.width = nw; r.height = nh;
|
||||||
|
}
|
||||||
|
|
||||||
|
renderPatternCanvas();
|
||||||
|
}
|
||||||
|
|
||||||
|
function _patternCanvasMouseUp() {
|
||||||
|
if (patternCanvasDragMode) {
|
||||||
|
patternCanvasDragMode = null;
|
||||||
|
patternCanvasDragStart = null;
|
||||||
|
patternCanvasDragOrigRect = null;
|
||||||
|
renderPatternRectList(); // sync inputs after drag
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function capturePatternBackground() {
|
||||||
|
const sourceId = document.getElementById('pattern-bg-source').value;
|
||||||
|
if (!sourceId) {
|
||||||
|
showToast(t('pattern.source_for_bg.none'), 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch(`${API_BASE}/picture-sources/${sourceId}/test`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: getHeaders(),
|
||||||
|
body: JSON.stringify({ capture_duration: 0 }),
|
||||||
|
});
|
||||||
|
if (!resp.ok) throw new Error('Failed to capture');
|
||||||
|
const data = await resp.json();
|
||||||
|
|
||||||
|
if (data.full_capture && data.full_capture.full_image) {
|
||||||
|
const img = new Image();
|
||||||
|
img.onload = () => {
|
||||||
|
patternEditorBgImage = img;
|
||||||
|
renderPatternCanvas();
|
||||||
|
};
|
||||||
|
img.src = data.full_capture.full_image;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Failed to capture background:', error);
|
||||||
|
showToast('Failed to capture background', 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -378,14 +378,11 @@
|
|||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
<div class="label-row">
|
<div class="label-row">
|
||||||
<label data-i18n="kc.rectangles">Color Rectangles</label>
|
<label for="kc-editor-pattern-template" data-i18n="kc.pattern_template">Pattern Template:</label>
|
||||||
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
|
||||||
</div>
|
</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>
|
<small class="input-hint" style="display:none" data-i18n="kc.pattern_template.hint">Select the rectangle pattern to use for color extraction</small>
|
||||||
<div id="kc-rect-list" class="kc-rect-list"></div>
|
<select id="kc-editor-pattern-template"></select>
|
||||||
<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>
|
||||||
|
|
||||||
<div id="kc-editor-error" class="error-message" style="display: none;"></div>
|
<div id="kc-editor-error" class="error-message" style="display: none;"></div>
|
||||||
@@ -398,6 +395,67 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Pattern Template Editor Modal -->
|
||||||
|
<div id="pattern-template-modal" class="modal">
|
||||||
|
<div class="modal-content modal-content-wide">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2 id="pattern-template-title" data-i18n="pattern.add">📄 Add Pattern Template</h2>
|
||||||
|
<button class="modal-close-btn" onclick="closePatternTemplateModal()" title="Close">✕</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<form id="pattern-template-form">
|
||||||
|
<input type="hidden" id="pattern-template-id">
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pattern-template-name" data-i18n="pattern.name">Template Name:</label>
|
||||||
|
<input type="text" id="pattern-template-name" data-i18n-placeholder="pattern.name.placeholder" placeholder="My Pattern Template" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="pattern-template-description" data-i18n="pattern.description_label">Description (optional):</label>
|
||||||
|
<input type="text" id="pattern-template-description" data-i18n-placeholder="pattern.description_placeholder" placeholder="Describe this pattern...">
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Visual Editor -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="pattern.visual_editor">Visual Editor</label>
|
||||||
|
<div class="pattern-bg-row">
|
||||||
|
<select id="pattern-bg-source"></select>
|
||||||
|
<button type="button" class="btn btn-icon btn-secondary pattern-capture-btn" onclick="capturePatternBackground()" title="Capture Background" data-i18n-title="pattern.capture_bg">📷</button>
|
||||||
|
</div>
|
||||||
|
<div class="pattern-canvas-container">
|
||||||
|
<canvas id="pattern-canvas"></canvas>
|
||||||
|
</div>
|
||||||
|
<div class="pattern-canvas-toolbar">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick="addPatternRect()">+ <span data-i18n="pattern.rect.add">Add Rectangle</span></button>
|
||||||
|
<button type="button" class="btn btn-danger" onclick="deleteSelectedPatternRect()" data-i18n="pattern.delete_selected">Delete Selected</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Precise coordinate list -->
|
||||||
|
<div class="form-group">
|
||||||
|
<label data-i18n="pattern.rectangles">Rectangles</label>
|
||||||
|
<div id="pattern-rect-labels" class="pattern-rect-labels">
|
||||||
|
<span data-i18n="pattern.rect.name">Name</span>
|
||||||
|
<span data-i18n="pattern.rect.x">X</span>
|
||||||
|
<span data-i18n="pattern.rect.y">Y</span>
|
||||||
|
<span data-i18n="pattern.rect.width">W</span>
|
||||||
|
<span data-i18n="pattern.rect.height">H</span>
|
||||||
|
<span></span>
|
||||||
|
</div>
|
||||||
|
<div id="pattern-rect-list" class="pattern-rect-list"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="pattern-template-error" class="error-message" style="display: none;"></div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button class="btn btn-icon btn-secondary" onclick="closePatternTemplateModal()" title="Cancel">✕</button>
|
||||||
|
<button class="btn btn-icon btn-primary" onclick="savePatternTemplate()" title="Save">✓</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Login Modal -->
|
<!-- Login Modal -->
|
||||||
<div id="api-key-modal" class="modal">
|
<div id="api-key-modal" class="modal">
|
||||||
<div class="modal-content">
|
<div class="modal-content">
|
||||||
|
|||||||
@@ -360,21 +360,41 @@
|
|||||||
"kc.interpolation.dominant": "Dominant",
|
"kc.interpolation.dominant": "Dominant",
|
||||||
"kc.smoothing": "Smoothing:",
|
"kc.smoothing": "Smoothing:",
|
||||||
"kc.smoothing.hint": "Temporal blending between extractions (0=none, 1=full)",
|
"kc.smoothing.hint": "Temporal blending between extractions (0=none, 1=full)",
|
||||||
"kc.rectangles": "Color Rectangles",
|
"kc.pattern_template": "Pattern Template:",
|
||||||
"kc.rectangles.hint": "Define named rectangles in relative coordinates (0.0–1.0) on the captured image",
|
"kc.pattern_template.hint": "Select the rectangle pattern to use for color extraction",
|
||||||
"kc.rect.name": "Name",
|
"kc.pattern_template.none": "-- Select a pattern template --",
|
||||||
"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.created": "Key colors target created successfully",
|
||||||
"kc.updated": "Key colors target updated successfully",
|
"kc.updated": "Key colors target updated successfully",
|
||||||
"kc.deleted": "Key colors target deleted successfully",
|
"kc.deleted": "Key colors target deleted successfully",
|
||||||
"kc.delete.confirm": "Are you sure you want to delete this key colors target?",
|
"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.no_pattern": "Please select a pattern template",
|
||||||
"kc.error.required": "Please fill in all required fields",
|
"kc.error.required": "Please fill in all required fields",
|
||||||
"kc.colors.none": "No colors extracted yet"
|
"kc.colors.none": "No colors extracted yet",
|
||||||
|
"targets.section.pattern_templates": "📄 Pattern Templates",
|
||||||
|
"pattern.add": "📄 Add Pattern Template",
|
||||||
|
"pattern.edit": "📄 Edit Pattern Template",
|
||||||
|
"pattern.name": "Template Name:",
|
||||||
|
"pattern.name.placeholder": "My Pattern Template",
|
||||||
|
"pattern.description_label": "Description (optional):",
|
||||||
|
"pattern.description_placeholder": "Describe this pattern...",
|
||||||
|
"pattern.rectangles": "Rectangles",
|
||||||
|
"pattern.rect.name": "Name",
|
||||||
|
"pattern.rect.x": "X",
|
||||||
|
"pattern.rect.y": "Y",
|
||||||
|
"pattern.rect.width": "W",
|
||||||
|
"pattern.rect.height": "H",
|
||||||
|
"pattern.rect.add": "Add Rectangle",
|
||||||
|
"pattern.rect.remove": "Remove",
|
||||||
|
"pattern.rect.empty": "No rectangles defined. Add at least one rectangle.",
|
||||||
|
"pattern.created": "Pattern template created successfully",
|
||||||
|
"pattern.updated": "Pattern template updated successfully",
|
||||||
|
"pattern.deleted": "Pattern template deleted successfully",
|
||||||
|
"pattern.delete.confirm": "Are you sure you want to delete this pattern template?",
|
||||||
|
"pattern.delete.referenced": "Cannot delete: this template is referenced by a target",
|
||||||
|
"pattern.error.required": "Please fill in all required fields",
|
||||||
|
"pattern.visual_editor": "Visual Editor",
|
||||||
|
"pattern.capture_bg": "Capture Background",
|
||||||
|
"pattern.source_for_bg": "Source for Background:",
|
||||||
|
"pattern.source_for_bg.none": "-- Select source --",
|
||||||
|
"pattern.delete_selected": "Delete Selected"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -360,21 +360,41 @@
|
|||||||
"kc.interpolation.dominant": "Доминантный",
|
"kc.interpolation.dominant": "Доминантный",
|
||||||
"kc.smoothing": "Сглаживание:",
|
"kc.smoothing": "Сглаживание:",
|
||||||
"kc.smoothing.hint": "Временное смешивание между извлечениями (0=нет, 1=полное)",
|
"kc.smoothing.hint": "Временное смешивание между извлечениями (0=нет, 1=полное)",
|
||||||
"kc.rectangles": "Цветовые Прямоугольники",
|
"kc.pattern_template": "Шаблон Паттерна:",
|
||||||
"kc.rectangles.hint": "Определите именованные прямоугольники в относительных координатах (0.0–1.0) на захваченном изображении",
|
"kc.pattern_template.hint": "Выберите шаблон прямоугольников для извлечения цветов",
|
||||||
"kc.rect.name": "Имя",
|
"kc.pattern_template.none": "-- Выберите шаблон паттерна --",
|
||||||
"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.created": "Цель ключевых цветов успешно создана",
|
||||||
"kc.updated": "Цель ключевых цветов успешно обновлена",
|
"kc.updated": "Цель ключевых цветов успешно обновлена",
|
||||||
"kc.deleted": "Цель ключевых цветов успешно удалена",
|
"kc.deleted": "Цель ключевых цветов успешно удалена",
|
||||||
"kc.delete.confirm": "Вы уверены, что хотите удалить эту цель ключевых цветов?",
|
"kc.delete.confirm": "Вы уверены, что хотите удалить эту цель ключевых цветов?",
|
||||||
"kc.error.no_rectangles": "Пожалуйста, добавьте хотя бы один прямоугольник",
|
"kc.error.no_pattern": "Пожалуйста, выберите шаблон паттерна",
|
||||||
"kc.error.required": "Пожалуйста, заполните все обязательные поля",
|
"kc.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||||
"kc.colors.none": "Цвета пока не извлечены"
|
"kc.colors.none": "Цвета пока не извлечены",
|
||||||
|
"targets.section.pattern_templates": "📄 Шаблоны Паттернов",
|
||||||
|
"pattern.add": "📄 Добавить Шаблон Паттерна",
|
||||||
|
"pattern.edit": "📄 Редактировать Шаблон Паттерна",
|
||||||
|
"pattern.name": "Имя Шаблона:",
|
||||||
|
"pattern.name.placeholder": "Мой Шаблон Паттерна",
|
||||||
|
"pattern.description_label": "Описание (необязательно):",
|
||||||
|
"pattern.description_placeholder": "Опишите этот паттерн...",
|
||||||
|
"pattern.rectangles": "Прямоугольники",
|
||||||
|
"pattern.rect.name": "Имя",
|
||||||
|
"pattern.rect.x": "X",
|
||||||
|
"pattern.rect.y": "Y",
|
||||||
|
"pattern.rect.width": "Ш",
|
||||||
|
"pattern.rect.height": "В",
|
||||||
|
"pattern.rect.add": "Добавить Прямоугольник",
|
||||||
|
"pattern.rect.remove": "Удалить",
|
||||||
|
"pattern.rect.empty": "Прямоугольники не определены. Добавьте хотя бы один.",
|
||||||
|
"pattern.created": "Шаблон паттерна успешно создан",
|
||||||
|
"pattern.updated": "Шаблон паттерна успешно обновлён",
|
||||||
|
"pattern.deleted": "Шаблон паттерна успешно удалён",
|
||||||
|
"pattern.delete.confirm": "Вы уверены, что хотите удалить этот шаблон паттерна?",
|
||||||
|
"pattern.delete.referenced": "Невозможно удалить: шаблон используется целью",
|
||||||
|
"pattern.error.required": "Пожалуйста, заполните все обязательные поля",
|
||||||
|
"pattern.visual_editor": "Визуальный Редактор",
|
||||||
|
"pattern.capture_bg": "Захватить Фон",
|
||||||
|
"pattern.source_for_bg": "Источник для Фона:",
|
||||||
|
"pattern.source_for_bg.none": "-- Выберите источник --",
|
||||||
|
"pattern.delete_selected": "Удалить Выбранный"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2706,3 +2706,157 @@ input:-webkit-autofill:focus {
|
|||||||
padding: 4px 0;
|
padding: 4px 0;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pattern Template Visual Editor */
|
||||||
|
.modal-content-wide {
|
||||||
|
max-width: 900px !important;
|
||||||
|
width: 95% !important;
|
||||||
|
max-height: calc(100vh - 40px);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content-wide .modal-body {
|
||||||
|
overflow-y: auto;
|
||||||
|
flex: 1 1 auto;
|
||||||
|
min-height: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-canvas-container {
|
||||||
|
position: relative;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
margin-bottom: 12px;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 200px;
|
||||||
|
height: 450px;
|
||||||
|
max-height: calc(100vh - 400px);
|
||||||
|
}
|
||||||
|
|
||||||
|
#pattern-canvas {
|
||||||
|
width: 100%;
|
||||||
|
height: 100%;
|
||||||
|
display: block;
|
||||||
|
cursor: crosshair;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-canvas-toolbar {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
padding: 8px 0;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-canvas-toolbar .btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: auto;
|
||||||
|
padding: 6px 12px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-bg-row {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-bg-row select {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-capture-btn {
|
||||||
|
flex: 0 0 auto;
|
||||||
|
min-width: 36px !important;
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
padding: 0 !important;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
line-height: 36px;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-list {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 6px;
|
||||||
|
margin-top: 8px;
|
||||||
|
max-height: 200px;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 6px 8px;
|
||||||
|
background: var(--bg-color);
|
||||||
|
border: 1px solid var(--border-color);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: border-color 0.15s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-row.selected {
|
||||||
|
border-color: var(--primary-color);
|
||||||
|
background: rgba(76, 175, 80, 0.08);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-row input[type="text"] {
|
||||||
|
flex: 2;
|
||||||
|
min-width: 0;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-row input[type="number"] {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
width: 55px;
|
||||||
|
padding: 4px 6px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-row .pattern-rect-remove-btn {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: #777;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 2px 4px;
|
||||||
|
border-radius: 4px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
transition: color 0.2s, background 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-row .pattern-rect-remove-btn:hover {
|
||||||
|
color: var(--danger-color);
|
||||||
|
background: rgba(244, 67, 54, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-labels {
|
||||||
|
display: flex;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 0 8px;
|
||||||
|
margin-bottom: 2px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-labels span:first-child {
|
||||||
|
flex: 2;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-labels span {
|
||||||
|
flex: 1;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pattern-rect-labels span:last-child {
|
||||||
|
width: 24px;
|
||||||
|
flex: 0 0 24px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,8 @@
|
|||||||
"""Storage layer for device and configuration persistence."""
|
"""Storage layer for device and configuration persistence."""
|
||||||
|
|
||||||
from .device_store import DeviceStore
|
from .device_store import DeviceStore
|
||||||
|
from .pattern_template_store import PatternTemplateStore
|
||||||
from .picture_source_store import PictureSourceStore
|
from .picture_source_store import PictureSourceStore
|
||||||
from .postprocessing_template_store import PostprocessingTemplateStore
|
from .postprocessing_template_store import PostprocessingTemplateStore
|
||||||
|
|
||||||
__all__ = ["DeviceStore", "PictureSourceStore", "PostprocessingTemplateStore"]
|
__all__ = ["DeviceStore", "PatternTemplateStore", "PictureSourceStore", "PostprocessingTemplateStore"]
|
||||||
|
|||||||
@@ -44,14 +44,14 @@ class KeyColorsSettings:
|
|||||||
fps: int = 10
|
fps: int = 10
|
||||||
interpolation_mode: str = "average"
|
interpolation_mode: str = "average"
|
||||||
smoothing: float = 0.3
|
smoothing: float = 0.3
|
||||||
rectangles: List[KeyColorRectangle] = field(default_factory=list)
|
pattern_template_id: str = ""
|
||||||
|
|
||||||
def to_dict(self) -> dict:
|
def to_dict(self) -> dict:
|
||||||
return {
|
return {
|
||||||
"fps": self.fps,
|
"fps": self.fps,
|
||||||
"interpolation_mode": self.interpolation_mode,
|
"interpolation_mode": self.interpolation_mode,
|
||||||
"smoothing": self.smoothing,
|
"smoothing": self.smoothing,
|
||||||
"rectangles": [r.to_dict() for r in self.rectangles],
|
"pattern_template_id": self.pattern_template_id,
|
||||||
}
|
}
|
||||||
|
|
||||||
@classmethod
|
@classmethod
|
||||||
@@ -60,10 +60,7 @@ class KeyColorsSettings:
|
|||||||
fps=data.get("fps", 10),
|
fps=data.get("fps", 10),
|
||||||
interpolation_mode=data.get("interpolation_mode", "average"),
|
interpolation_mode=data.get("interpolation_mode", "average"),
|
||||||
smoothing=data.get("smoothing", 0.3),
|
smoothing=data.get("smoothing", 0.3),
|
||||||
rectangles=[
|
pattern_template_id=data.get("pattern_template_id", ""),
|
||||||
KeyColorRectangle.from_dict(r)
|
|
||||||
for r in data.get("rectangles", [])
|
|
||||||
],
|
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
47
server/src/wled_controller/storage/pattern_template.py
Normal file
47
server/src/wled_controller/storage/pattern_template.py
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
"""Pattern template data model for key color rectangle layouts."""
|
||||||
|
|
||||||
|
from dataclasses import dataclass, field
|
||||||
|
from datetime import datetime
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from wled_controller.storage.key_colors_picture_target import KeyColorRectangle
|
||||||
|
|
||||||
|
|
||||||
|
@dataclass
|
||||||
|
class PatternTemplate:
|
||||||
|
"""Pattern template containing a named layout of key color rectangles."""
|
||||||
|
|
||||||
|
id: str
|
||||||
|
name: str
|
||||||
|
rectangles: List[KeyColorRectangle]
|
||||||
|
created_at: datetime
|
||||||
|
updated_at: datetime
|
||||||
|
description: Optional[str] = None
|
||||||
|
|
||||||
|
def to_dict(self) -> dict:
|
||||||
|
"""Convert to dictionary."""
|
||||||
|
return {
|
||||||
|
"id": self.id,
|
||||||
|
"name": self.name,
|
||||||
|
"rectangles": [r.to_dict() for r in self.rectangles],
|
||||||
|
"created_at": self.created_at.isoformat(),
|
||||||
|
"updated_at": self.updated_at.isoformat(),
|
||||||
|
"description": self.description,
|
||||||
|
}
|
||||||
|
|
||||||
|
@classmethod
|
||||||
|
def from_dict(cls, data: dict) -> "PatternTemplate":
|
||||||
|
"""Create from dictionary."""
|
||||||
|
rectangles = [KeyColorRectangle.from_dict(r) for r in data.get("rectangles", [])]
|
||||||
|
return cls(
|
||||||
|
id=data["id"],
|
||||||
|
name=data["name"],
|
||||||
|
rectangles=rectangles,
|
||||||
|
created_at=datetime.fromisoformat(data["created_at"])
|
||||||
|
if isinstance(data.get("created_at"), str)
|
||||||
|
else data.get("created_at", datetime.utcnow()),
|
||||||
|
updated_at=datetime.fromisoformat(data["updated_at"])
|
||||||
|
if isinstance(data.get("updated_at"), str)
|
||||||
|
else data.get("updated_at", datetime.utcnow()),
|
||||||
|
description=data.get("description"),
|
||||||
|
)
|
||||||
225
server/src/wled_controller/storage/pattern_template_store.py
Normal file
225
server/src/wled_controller/storage/pattern_template_store.py
Normal file
@@ -0,0 +1,225 @@
|
|||||||
|
"""Pattern template storage using JSON files."""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import uuid
|
||||||
|
from datetime import datetime
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Dict, List, Optional
|
||||||
|
|
||||||
|
from wled_controller.storage.key_colors_picture_target import KeyColorRectangle
|
||||||
|
from wled_controller.storage.pattern_template import PatternTemplate
|
||||||
|
from wled_controller.utils import get_logger
|
||||||
|
|
||||||
|
logger = get_logger(__name__)
|
||||||
|
|
||||||
|
|
||||||
|
class PatternTemplateStore:
|
||||||
|
"""Storage for pattern templates (rectangle layouts for key color extraction).
|
||||||
|
|
||||||
|
All templates are persisted to the JSON file.
|
||||||
|
On startup, if no templates exist, a default one is auto-created.
|
||||||
|
"""
|
||||||
|
|
||||||
|
def __init__(self, file_path: str):
|
||||||
|
"""Initialize pattern template store.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
file_path: Path to templates JSON file
|
||||||
|
"""
|
||||||
|
self.file_path = Path(file_path)
|
||||||
|
self._templates: Dict[str, PatternTemplate] = {}
|
||||||
|
self._load()
|
||||||
|
self._ensure_initial_template()
|
||||||
|
|
||||||
|
def _ensure_initial_template(self) -> None:
|
||||||
|
"""Auto-create a default pattern template if none exist."""
|
||||||
|
if self._templates:
|
||||||
|
return
|
||||||
|
|
||||||
|
now = datetime.utcnow()
|
||||||
|
template_id = f"pat_{uuid.uuid4().hex[:8]}"
|
||||||
|
|
||||||
|
template = PatternTemplate(
|
||||||
|
id=template_id,
|
||||||
|
name="Default",
|
||||||
|
rectangles=[
|
||||||
|
KeyColorRectangle(name="Full Frame", x=0.0, y=0.0, width=1.0, height=1.0),
|
||||||
|
],
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
description="Default pattern template with full-frame rectangle",
|
||||||
|
)
|
||||||
|
|
||||||
|
self._templates[template_id] = template
|
||||||
|
self._save()
|
||||||
|
logger.info(f"Auto-created initial pattern template: {template.name} ({template_id})")
|
||||||
|
|
||||||
|
def _load(self) -> None:
|
||||||
|
"""Load templates from file."""
|
||||||
|
if not self.file_path.exists():
|
||||||
|
return
|
||||||
|
|
||||||
|
try:
|
||||||
|
with open(self.file_path, "r", encoding="utf-8") as f:
|
||||||
|
data = json.load(f)
|
||||||
|
|
||||||
|
templates_data = data.get("pattern_templates", {})
|
||||||
|
loaded = 0
|
||||||
|
for template_id, template_dict in templates_data.items():
|
||||||
|
try:
|
||||||
|
template = PatternTemplate.from_dict(template_dict)
|
||||||
|
self._templates[template_id] = template
|
||||||
|
loaded += 1
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(
|
||||||
|
f"Failed to load pattern template {template_id}: {e}",
|
||||||
|
exc_info=True,
|
||||||
|
)
|
||||||
|
|
||||||
|
if loaded > 0:
|
||||||
|
logger.info(f"Loaded {loaded} pattern templates from storage")
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to load pattern templates from {self.file_path}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
logger.info(f"Pattern template store initialized with {len(self._templates)} templates")
|
||||||
|
|
||||||
|
def _save(self) -> None:
|
||||||
|
"""Save all templates to file."""
|
||||||
|
try:
|
||||||
|
self.file_path.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
|
||||||
|
templates_dict = {
|
||||||
|
template_id: template.to_dict()
|
||||||
|
for template_id, template in self._templates.items()
|
||||||
|
}
|
||||||
|
|
||||||
|
data = {
|
||||||
|
"version": "1.0.0",
|
||||||
|
"pattern_templates": templates_dict,
|
||||||
|
}
|
||||||
|
|
||||||
|
with open(self.file_path, "w", encoding="utf-8") as f:
|
||||||
|
json.dump(data, f, indent=2, ensure_ascii=False)
|
||||||
|
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"Failed to save pattern templates to {self.file_path}: {e}")
|
||||||
|
raise
|
||||||
|
|
||||||
|
def get_all_templates(self) -> List[PatternTemplate]:
|
||||||
|
"""Get all pattern templates."""
|
||||||
|
return list(self._templates.values())
|
||||||
|
|
||||||
|
def get_template(self, template_id: str) -> PatternTemplate:
|
||||||
|
"""Get template by ID.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If template not found
|
||||||
|
"""
|
||||||
|
if template_id not in self._templates:
|
||||||
|
raise ValueError(f"Pattern template not found: {template_id}")
|
||||||
|
return self._templates[template_id]
|
||||||
|
|
||||||
|
def create_template(
|
||||||
|
self,
|
||||||
|
name: str,
|
||||||
|
rectangles: Optional[List[KeyColorRectangle]] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> PatternTemplate:
|
||||||
|
"""Create a new pattern template.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
name: Template name (must be unique)
|
||||||
|
rectangles: List of named rectangles
|
||||||
|
description: Optional description
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If template with same name exists
|
||||||
|
"""
|
||||||
|
for template in self._templates.values():
|
||||||
|
if template.name == name:
|
||||||
|
raise ValueError(f"Pattern template with name '{name}' already exists")
|
||||||
|
|
||||||
|
if rectangles is None:
|
||||||
|
rectangles = []
|
||||||
|
|
||||||
|
template_id = f"pat_{uuid.uuid4().hex[:8]}"
|
||||||
|
now = datetime.utcnow()
|
||||||
|
|
||||||
|
template = PatternTemplate(
|
||||||
|
id=template_id,
|
||||||
|
name=name,
|
||||||
|
rectangles=rectangles,
|
||||||
|
created_at=now,
|
||||||
|
updated_at=now,
|
||||||
|
description=description,
|
||||||
|
)
|
||||||
|
|
||||||
|
self._templates[template_id] = template
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
logger.info(f"Created pattern template: {name} ({template_id})")
|
||||||
|
return template
|
||||||
|
|
||||||
|
def update_template(
|
||||||
|
self,
|
||||||
|
template_id: str,
|
||||||
|
name: Optional[str] = None,
|
||||||
|
rectangles: Optional[List[KeyColorRectangle]] = None,
|
||||||
|
description: Optional[str] = None,
|
||||||
|
) -> PatternTemplate:
|
||||||
|
"""Update an existing pattern template.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If template not found
|
||||||
|
"""
|
||||||
|
if template_id not in self._templates:
|
||||||
|
raise ValueError(f"Pattern template not found: {template_id}")
|
||||||
|
|
||||||
|
template = self._templates[template_id]
|
||||||
|
|
||||||
|
if name is not None:
|
||||||
|
template.name = name
|
||||||
|
if rectangles is not None:
|
||||||
|
template.rectangles = rectangles
|
||||||
|
if description is not None:
|
||||||
|
template.description = description
|
||||||
|
|
||||||
|
template.updated_at = datetime.utcnow()
|
||||||
|
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
logger.info(f"Updated pattern template: {template_id}")
|
||||||
|
return template
|
||||||
|
|
||||||
|
def delete_template(self, template_id: str) -> None:
|
||||||
|
"""Delete a pattern template.
|
||||||
|
|
||||||
|
Raises:
|
||||||
|
ValueError: If template not found
|
||||||
|
"""
|
||||||
|
if template_id not in self._templates:
|
||||||
|
raise ValueError(f"Pattern template not found: {template_id}")
|
||||||
|
|
||||||
|
del self._templates[template_id]
|
||||||
|
self._save()
|
||||||
|
|
||||||
|
logger.info(f"Deleted pattern template: {template_id}")
|
||||||
|
|
||||||
|
def is_referenced_by(self, template_id: str, picture_target_store) -> bool:
|
||||||
|
"""Check if this template is referenced by any key colors target.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
template_id: Template ID to check
|
||||||
|
picture_target_store: PictureTargetStore instance
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
True if any KC target references this template
|
||||||
|
"""
|
||||||
|
from wled_controller.storage.key_colors_picture_target import KeyColorsPictureTarget
|
||||||
|
|
||||||
|
for target in picture_target_store.get_all_targets():
|
||||||
|
if isinstance(target, KeyColorsPictureTarget) and target.settings.pattern_template_id == template_id:
|
||||||
|
return True
|
||||||
|
return False
|
||||||
Reference in New Issue
Block a user