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:
@@ -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"]
|
||||
|
||||
@@ -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", ""),
|
||||
)
|
||||
|
||||
|
||||
|
||||
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