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:
2026-02-12 18:07:40 +03:00
parent 5f9bc9a37e
commit 87e7eee743
21 changed files with 1423 additions and 150 deletions

View File

@@ -1,7 +1,8 @@
"""Storage layer for device and configuration persistence."""
from .device_store import DeviceStore
from .pattern_template_store import PatternTemplateStore
from .picture_source_store import PictureSourceStore
from .postprocessing_template_store import PostprocessingTemplateStore
__all__ = ["DeviceStore", "PictureSourceStore", "PostprocessingTemplateStore"]
__all__ = ["DeviceStore", "PatternTemplateStore", "PictureSourceStore", "PostprocessingTemplateStore"]

View File

@@ -44,14 +44,14 @@ class KeyColorsSettings:
fps: int = 10
interpolation_mode: str = "average"
smoothing: float = 0.3
rectangles: List[KeyColorRectangle] = field(default_factory=list)
pattern_template_id: str = ""
def to_dict(self) -> dict:
return {
"fps": self.fps,
"interpolation_mode": self.interpolation_mode,
"smoothing": self.smoothing,
"rectangles": [r.to_dict() for r in self.rectangles],
"pattern_template_id": self.pattern_template_id,
}
@classmethod
@@ -60,10 +60,7 @@ class KeyColorsSettings:
fps=data.get("fps", 10),
interpolation_mode=data.get("interpolation_mode", "average"),
smoothing=data.get("smoothing", 0.3),
rectangles=[
KeyColorRectangle.from_dict(r)
for r in data.get("rectangles", [])
],
pattern_template_id=data.get("pattern_template_id", ""),
)

View 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"),
)

View 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