diff --git a/server/src/wled_controller/api/__init__.py b/server/src/wled_controller/api/__init__.py
index 0ac6fa2..9fcdc3f 100644
--- a/server/src/wled_controller/api/__init__.py
+++ b/server/src/wled_controller/api/__init__.py
@@ -7,6 +7,7 @@ from .routes.devices import router as devices_router
from .routes.templates import router as templates_router
from .routes.postprocessing import router as postprocessing_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
router = APIRouter()
@@ -14,6 +15,7 @@ router.include_router(system_router)
router.include_router(devices_router)
router.include_router(templates_router)
router.include_router(postprocessing_router)
+router.include_router(pattern_templates_router)
router.include_router(picture_sources_router)
router.include_router(picture_targets_router)
diff --git a/server/src/wled_controller/api/dependencies.py b/server/src/wled_controller/api/dependencies.py
index 4c93bd0..3519c48 100644
--- a/server/src/wled_controller/api/dependencies.py
+++ b/server/src/wled_controller/api/dependencies.py
@@ -4,6 +4,7 @@ from wled_controller.core.processor_manager import ProcessorManager
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
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_target_store import PictureTargetStore
@@ -11,6 +12,7 @@ from wled_controller.storage.picture_target_store import PictureTargetStore
_device_store: DeviceStore | None = None
_template_store: TemplateStore | None = None
_pp_template_store: PostprocessingTemplateStore | None = None
+_pattern_template_store: PatternTemplateStore | None = None
_picture_source_store: PictureSourceStore | None = None
_picture_target_store: PictureTargetStore | None = None
_processor_manager: ProcessorManager | None = None
@@ -37,6 +39,13 @@ def get_pp_template_store() -> PostprocessingTemplateStore:
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:
"""Get picture source store dependency."""
if _picture_source_store is None:
@@ -63,15 +72,17 @@ def init_dependencies(
template_store: TemplateStore,
processor_manager: ProcessorManager,
pp_template_store: PostprocessingTemplateStore | None = None,
+ pattern_template_store: PatternTemplateStore | None = None,
picture_source_store: PictureSourceStore | None = None,
picture_target_store: PictureTargetStore | None = None,
):
"""Initialize global dependencies."""
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
_template_store = template_store
_processor_manager = processor_manager
_pp_template_store = pp_template_store
+ _pattern_template_store = pattern_template_store
_picture_source_store = picture_source_store
_picture_target_store = picture_target_store
diff --git a/server/src/wled_controller/api/routes/pattern_templates.py b/server/src/wled_controller/api/routes/pattern_templates.py
new file mode 100644
index 0000000..c0e1bc5
--- /dev/null
+++ b/server/src/wled_controller/api/routes/pattern_templates.py
@@ -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))
diff --git a/server/src/wled_controller/api/routes/picture_sources.py b/server/src/wled_controller/api/routes/picture_sources.py
index f9dad08..ecf64b8 100644
--- a/server/src/wled_controller/api/routes/picture_sources.py
+++ b/server/src/wled_controller/api/routes/picture_sources.py
@@ -365,26 +365,33 @@ async def test_picture_source(
)
stream.initialize()
- logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}")
-
frame_count = 0
total_capture_time = 0.0
last_frame = None
-
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()
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
+ if screen_capture is not None:
+ total_capture_time = capture_elapsed
+ frame_count = 1
+ last_frame = screen_capture
+ else:
+ logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}")
+ 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
diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py
index 2c2efd4..3d72b9a 100644
--- a/server/src/wled_controller/api/routes/picture_targets.py
+++ b/server/src/wled_controller/api/routes/picture_targets.py
@@ -12,7 +12,6 @@ from wled_controller.api.dependencies import (
)
from wled_controller.api.schemas.picture_targets import (
ExtractedColorResponse,
- KeyColorRectangleSchema,
KeyColorsResponse,
KeyColorsSettingsSchema,
PictureTargetCreate,
@@ -28,7 +27,6 @@ from wled_controller.core.processor_manager import ProcessorManager, ProcessingS
from wled_controller.storage import DeviceStore
from wled_controller.storage.wled_picture_target import WledPictureTarget
from wled_controller.storage.key_colors_picture_target import (
- KeyColorRectangle,
KeyColorsSettings,
KeyColorsPictureTarget,
)
@@ -84,10 +82,7 @@ def _kc_settings_to_schema(settings: KeyColorsSettings) -> KeyColorsSettingsSche
fps=settings.fps,
interpolation_mode=settings.interpolation_mode,
smoothing=settings.smoothing,
- rectangles=[
- KeyColorRectangleSchema(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
- for r in settings.rectangles
- ],
+ pattern_template_id=settings.pattern_template_id,
)
@@ -97,10 +92,7 @@ def _kc_schema_to_settings(schema: KeyColorsSettingsSchema) -> KeyColorsSettings
fps=schema.fps,
interpolation_mode=schema.interpolation_mode,
smoothing=schema.smoothing,
- rectangles=[
- KeyColorRectangle(name=r.name, x=r.x, y=r.y, width=r.width, height=r.height)
- for r in schema.rectangles
- ],
+ pattern_template_id=schema.pattern_template_id,
)
diff --git a/server/src/wled_controller/api/schemas/__init__.py b/server/src/wled_controller/api/schemas/__init__.py
index daba863..45859dc 100644
--- a/server/src/wled_controller/api/schemas/__init__.py
+++ b/server/src/wled_controller/api/schemas/__init__.py
@@ -56,6 +56,12 @@ from .postprocessing import (
PostprocessingTemplateUpdate,
PPTemplateTestRequest,
)
+from .pattern_templates import (
+ PatternTemplateCreate,
+ PatternTemplateListResponse,
+ PatternTemplateResponse,
+ PatternTemplateUpdate,
+)
from .picture_sources import (
ImageValidateRequest,
ImageValidateResponse,
@@ -109,6 +115,10 @@ __all__ = [
"PostprocessingTemplateResponse",
"PostprocessingTemplateUpdate",
"PPTemplateTestRequest",
+ "PatternTemplateCreate",
+ "PatternTemplateListResponse",
+ "PatternTemplateResponse",
+ "PatternTemplateUpdate",
"ImageValidateRequest",
"ImageValidateResponse",
"PictureSourceCreate",
diff --git a/server/src/wled_controller/api/schemas/pattern_templates.py b/server/src/wled_controller/api/schemas/pattern_templates.py
new file mode 100644
index 0000000..f9d1d89
--- /dev/null
+++ b/server/src/wled_controller/api/schemas/pattern_templates.py
@@ -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")
diff --git a/server/src/wled_controller/api/schemas/picture_sources.py b/server/src/wled_controller/api/schemas/picture_sources.py
index fa0f099..9e301d7 100644
--- a/server/src/wled_controller/api/schemas/picture_sources.py
+++ b/server/src/wled_controller/api/schemas/picture_sources.py
@@ -60,7 +60,7 @@ class PictureSourceListResponse(BaseModel):
class PictureSourceTestRequest(BaseModel):
"""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")
diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py
index 0a6acf2..9769271 100644
--- a/server/src/wled_controller/api/schemas/picture_targets.py
+++ b/server/src/wled_controller/api/schemas/picture_targets.py
@@ -51,7 +51,7 @@ class KeyColorsSettingsSchema(BaseModel):
fps: int = Field(default=10, description="Extraction rate (1-60)", ge=1, le=60)
interpolation_mode: str = Field(default="average", description="Color mode (average, median, dominant)")
smoothing: float = Field(default=0.3, description="Temporal smoothing (0.0-1.0)", ge=0.0, le=1.0)
- rectangles: List[KeyColorRectangleSchema] = Field(default_factory=list, description="Rectangles to extract colors from")
+ pattern_template_id: str = Field(default="", description="Pattern template ID for rectangle layout")
class ExtractedColorResponse(BaseModel):
diff --git a/server/src/wled_controller/config.py b/server/src/wled_controller/config.py
index 87333ac..02d4f90 100644
--- a/server/src/wled_controller/config.py
+++ b/server/src/wled_controller/config.py
@@ -58,6 +58,7 @@ class StorageConfig(BaseSettings):
postprocessing_templates_file: str = "data/postprocessing_templates.json"
picture_sources_file: str = "data/picture_sources.json"
picture_targets_file: str = "data/picture_targets.json"
+ pattern_templates_file: str = "data/pattern_templates.json"
class LoggingConfig(BaseSettings):
diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py
index 4e88c92..62c8fa8 100644
--- a/server/src/wled_controller/core/processor_manager.py
+++ b/server/src/wled_controller/core/processor_manager.py
@@ -155,7 +155,7 @@ class ProcessorManager:
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."""
self._devices: Dict[str, DeviceState] = {}
self._targets: Dict[str, TargetState] = {}
@@ -165,6 +165,7 @@ class ProcessorManager:
self._picture_source_store = picture_source_store
self._capture_template_store = capture_template_store
self._pp_template_store = pp_template_store
+ self._pattern_template_store = pattern_template_store
self._live_stream_manager = LiveStreamManager(
picture_source_store, capture_template_store, pp_template_store
)
@@ -1054,8 +1055,19 @@ class ProcessorManager:
if not state.picture_source_id:
raise ValueError(f"KC target {target_id} has no picture source assigned")
- if not state.settings.rectangles:
- raise ValueError(f"KC target {target_id} has no rectangles defined")
+ if not state.settings.pattern_template_id:
+ 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
try:
@@ -1133,9 +1145,11 @@ class ProcessorManager:
frame_time = 1.0 / target_fps
fps_samples: List[float] = []
+ rectangles = state._resolved_rectangles
+
logger.info(
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:
@@ -1152,7 +1166,7 @@ class ProcessorManager:
h, w = img.shape[:2]
colors: Dict[str, Tuple[int, int, int]] = {}
- for rect in settings.rectangles:
+ for rect in rectangles:
# Convert relative coords to pixel coords
px_x = max(0, int(rect.x * w))
px_y = max(0, int(rect.y * h))
diff --git a/server/src/wled_controller/main.py b/server/src/wled_controller/main.py
index 0dcfa9e..fa01ffe 100644
--- a/server/src/wled_controller/main.py
+++ b/server/src/wled_controller/main.py
@@ -17,6 +17,7 @@ from wled_controller.core.processor_manager import ProcessorManager, ProcessingS
from wled_controller.storage import DeviceStore
from wled_controller.storage.template_store import TemplateStore
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_target_store import PictureTargetStore
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)
picture_source_store = PictureSourceStore(config.storage.picture_sources_file)
picture_target_store = PictureTargetStore(config.storage.picture_targets_file)
+pattern_template_store = PatternTemplateStore(config.storage.pattern_templates_file)
processor_manager = ProcessorManager(
picture_source_store=picture_source_store,
capture_template_store=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("All API requests require valid Bearer token authentication")
- # Run migration from legacy device settings to picture targets
+ # Run migrations
_migrate_devices_to_targets()
# Initialize API dependencies
init_dependencies(
device_store, template_store, processor_manager,
pp_template_store=pp_template_store,
+ pattern_template_store=pattern_template_store,
picture_source_store=picture_source_store,
picture_target_store=picture_target_store,
)
diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js
index 4152968..205de4c 100644
--- a/server/src/wled_controller/static/app.js
+++ b/server/src/wled_controller/static/app.js
@@ -3961,11 +3961,12 @@ async function loadTargetsTab() {
if (!container) return;
try {
- // Fetch devices, targets, and sources in parallel
- const [devicesResp, targetsResp, sourcesResp] = await Promise.all([
+ // Fetch devices, targets, sources, and pattern templates in parallel
+ const [devicesResp, targetsResp, sourcesResp, patResp] = await Promise.all([
fetch(`${API_BASE}/devices`, { headers: getHeaders() }),
fetch(`${API_BASE}/picture-targets`, { headers: getHeaders() }),
fetchWithAuth('/picture-sources').catch(() => null),
+ fetchWithAuth('/pattern-templates').catch(() => null),
]);
if (devicesResp.status === 401 || targetsResp.status === 401) {
@@ -3985,6 +3986,14 @@ async function loadTargetsTab() {
(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
const devicesWithState = await Promise.all(
devices.map(async (device) => {
@@ -4033,7 +4042,7 @@ async function loadTargetsTab() {
const subTabs = [
{ key: 'wled', icon: '💡', titleKey: 'targets.subtab.wled', count: wledDevices.length + wledTargets.length },
- { key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length },
+ { key: 'key_colors', icon: '🎨', titleKey: 'targets.subtab.key_colors', count: kcTargets.length + patternTemplates.length },
];
const tabBar = `
${subTabs.map(tab =>
@@ -4069,12 +4078,21 @@ async function loadTargetsTab() {
- ${kcTargets.map(target => createKCTargetCard(target, sourceMap)).join('')}
+ ${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
+
+
+
+ ${patternTemplates.map(pt => createPatternTemplateCard(pt)).join('')}
+
+
+
`;
container.innerHTML = tabBar + wledPanel + kcPanel;
@@ -4244,7 +4262,7 @@ async function deleteTarget(targetId) {
// ===== KEY COLORS TARGET CARD =====
-function createKCTargetCard(target, sourceMap) {
+function createKCTargetCard(target, sourceMap, patternTemplateMap) {
const state = target.state || {};
const metrics = target.metrics || {};
const kcSettings = target.key_colors_settings || {};
@@ -4253,7 +4271,9 @@ function createKCTargetCard(target, sourceMap) {
const source = sourceMap[target.picture_source_id];
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
let swatchesHtml = '';
@@ -4281,7 +4301,8 @@ function createKCTargetCard(target, sourceMap) {
📺 ${escapeHtml(sourceName)}
- ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}
+ 📄 ${escapeHtml(patternName)}
+ ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}
@@ -4328,73 +4349,33 @@ function createKCTargetCard(target, sourceMap) {
// ===== KEY COLORS EDITOR =====
-let kcEditorRectangles = [];
let kcEditorInitialValues = {};
let _kcNameManuallyEdited = false;
function _autoGenerateKCName() {
if (_kcNameManuallyEdited) return;
- if (document.getElementById('kc-editor-id').value) return; // editing, not creating
+ if (document.getElementById('kc-editor-id').value) return;
const sourceSelect = document.getElementById('kc-editor-source');
const sourceName = sourceSelect.selectedOptions[0]?.dataset?.name || '';
if (!sourceName) return;
- const rectCount = kcEditorRectangles.length;
const mode = document.getElementById('kc-editor-interpolation').value || 'average';
const modeName = t(`kc.interpolation.${mode}`);
- document.getElementById('kc-editor-name').value = `${sourceName} [${rectCount}](${modeName})`;
-}
-
-function addKCRectangle(name = '', x = 0.0, y = 0.0, width = 1.0, height = 1.0) {
- kcEditorRectangles.push({ name: name || `Zone ${kcEditorRectangles.length + 1}`, x, y, width, height });
- renderKCRectangles();
- _autoGenerateKCName();
-}
-
-function removeKCRectangle(index) {
- kcEditorRectangles.splice(index, 1);
- renderKCRectangles();
- _autoGenerateKCName();
-}
-
-function renderKCRectangles() {
- const container = document.getElementById('kc-rect-list');
- if (!container) return;
-
- if (kcEditorRectangles.length === 0) {
- container.innerHTML = `
${t('kc.rect.empty')}
`;
- return;
- }
-
- const labels = `
- ${t('kc.rect.name')}
- ${t('kc.rect.x')}
- ${t('kc.rect.y')}
- ${t('kc.rect.width')}
- ${t('kc.rect.height')}
-
-
`;
-
- const rows = kcEditorRectangles.map((rect, i) => `
-
-
-
-
-
-
-
-
- `).join('');
-
- container.innerHTML = labels + rows;
+ const patSelect = document.getElementById('kc-editor-pattern-template');
+ const patName = patSelect.selectedOptions[0]?.textContent?.trim() || '';
+ document.getElementById('kc-editor-name').value = `${sourceName} · ${patName} (${modeName})`;
}
async function showKCEditor(targetId = null) {
try {
- // Load sources for dropdown
- const sourcesResp = await fetchWithAuth('/picture-sources').catch(() => null);
+ // Load sources and pattern templates in parallel
+ 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 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');
sourceSelect.innerHTML = '';
sources.forEach(s => {
@@ -4406,8 +4387,18 @@ async function showKCEditor(targetId = null) {
sourceSelect.appendChild(opt);
});
+ // Populate pattern template select
+ const patSelect = document.getElementById('kc-editor-pattern-template');
+ patSelect.innerHTML = `
`;
+ 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) {
- // Editing existing target
const resp = await fetch(`${API_BASE}/picture-targets/${targetId}`, { headers: getHeaders() });
if (!resp.ok) throw new Error('Failed to load target');
const target = await resp.json();
@@ -4421,11 +4412,9 @@ async function showKCEditor(targetId = null) {
document.getElementById('kc-editor-interpolation').value = kcSettings.interpolation_mode ?? 'average';
document.getElementById('kc-editor-smoothing').value = kcSettings.smoothing ?? 0.3;
document.getElementById('kc-editor-smoothing-value').textContent = kcSettings.smoothing ?? 0.3;
+ patSelect.value = kcSettings.pattern_template_id || '';
document.getElementById('kc-editor-title').textContent = t('kc.edit');
-
- kcEditorRectangles = (kcSettings.rectangles || []).map(r => ({ ...r }));
} else {
- // Creating new target
document.getElementById('kc-editor-id').value = '';
document.getElementById('kc-editor-name').value = '';
if (sourceSelect.options.length > 0) sourceSelect.selectedIndex = 0;
@@ -4434,20 +4423,16 @@ async function showKCEditor(targetId = null) {
document.getElementById('kc-editor-interpolation').value = 'average';
document.getElementById('kc-editor-smoothing').value = 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');
-
- kcEditorRectangles = [];
}
- renderKCRectangles();
-
- // Auto-name: reset flag and wire listeners
- _kcNameManuallyEdited = !!targetId; // treat edit mode as manually edited
+ // Auto-name
+ _kcNameManuallyEdited = !!targetId;
document.getElementById('kc-editor-name').oninput = () => { _kcNameManuallyEdited = true; };
sourceSelect.onchange = () => _autoGenerateKCName();
document.getElementById('kc-editor-interpolation').onchange = () => _autoGenerateKCName();
-
- // Trigger auto-name after dropdowns are populated (create mode only)
+ patSelect.onchange = () => _autoGenerateKCName();
if (!targetId) _autoGenerateKCName();
kcEditorInitialValues = {
@@ -4456,7 +4441,7 @@ async function showKCEditor(targetId = null) {
fps: document.getElementById('kc-editor-fps').value,
interpolation: document.getElementById('kc-editor-interpolation').value,
smoothing: document.getElementById('kc-editor-smoothing').value,
- rectangles: JSON.stringify(kcEditorRectangles),
+ patternTemplateId: patSelect.value,
};
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-interpolation').value !== kcEditorInitialValues.interpolation ||
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';
unlockBody();
kcEditorInitialValues = {};
- kcEditorRectangles = [];
}
async function saveKCEditor() {
@@ -4506,6 +4490,7 @@ async function saveKCEditor() {
const fps = parseInt(document.getElementById('kc-editor-fps').value) || 10;
const interpolation = document.getElementById('kc-editor-interpolation').value;
const smoothing = parseFloat(document.getElementById('kc-editor-smoothing').value);
+ const patternTemplateId = document.getElementById('kc-editor-pattern-template').value;
const errorEl = document.getElementById('kc-editor-error');
if (!name) {
@@ -4514,8 +4499,8 @@ async function saveKCEditor() {
return;
}
- if (kcEditorRectangles.length === 0) {
- errorEl.textContent = t('kc.error.no_rectangles');
+ if (!patternTemplateId) {
+ errorEl.textContent = t('kc.error.no_pattern');
errorEl.style.display = 'block';
return;
}
@@ -4527,13 +4512,7 @@ async function saveKCEditor() {
fps,
interpolation_mode: interpolation,
smoothing,
- rectangles: kcEditorRectangles.map(r => ({
- name: r.name,
- x: r.x,
- y: r.y,
- width: r.width,
- height: r.height,
- })),
+ pattern_template_id: patternTemplateId,
},
};
@@ -4663,3 +4642,545 @@ function updateKCColorSwatches(targetId, colors) {
`).join('');
}
+
+// ===== PATTERN TEMPLATES =====
+
+function createPatternTemplateCard(pt) {
+ const rectCount = (pt.rectangles || []).length;
+ const desc = pt.description ? `
${escapeHtml(pt.description)}
` : '';
+ return `
+
+
+
+ ${desc}
+
+ ▭ ${rectCount} rect${rectCount !== 1 ? 's' : ''}
+
+
+
+
+
+ `;
+}
+
+// ----- 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 = `
${t('pattern.rect.empty')}
`;
+ return;
+ }
+
+ container.innerHTML = patternEditorRects.map((rect, i) => `
+
+
+
+
+
+
+
+
+ `).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');
+ }
+}
diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html
index c1809de..c782440 100644
--- a/server/src/wled_controller/static/index.html
+++ b/server/src/wled_controller/static/index.html
@@ -378,14 +378,11 @@
@@ -398,6 +395,67 @@
+
+
+
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index 843f276..c6ddbda 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -360,21 +360,41 @@
"kc.interpolation.dominant": "Dominant",
"kc.smoothing": "Smoothing:",
"kc.smoothing.hint": "Temporal blending between extractions (0=none, 1=full)",
- "kc.rectangles": "Color Rectangles",
- "kc.rectangles.hint": "Define named rectangles in relative coordinates (0.0–1.0) on the captured image",
- "kc.rect.name": "Name",
- "kc.rect.x": "X",
- "kc.rect.y": "Y",
- "kc.rect.width": "W",
- "kc.rect.height": "H",
- "kc.rect.add": "Add Rectangle",
- "kc.rect.remove": "Remove",
- "kc.rect.empty": "No rectangles defined. Add at least one rectangle to extract colors.",
+ "kc.pattern_template": "Pattern Template:",
+ "kc.pattern_template.hint": "Select the rectangle pattern to use for color extraction",
+ "kc.pattern_template.none": "-- Select a pattern template --",
"kc.created": "Key colors target created successfully",
"kc.updated": "Key colors target updated successfully",
"kc.deleted": "Key colors target deleted successfully",
"kc.delete.confirm": "Are you sure you want to delete this key colors target?",
- "kc.error.no_rectangles": "Please add at least one rectangle",
+ "kc.error.no_pattern": "Please select a pattern template",
"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"
}
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index c4fc950..795ace8 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -360,21 +360,41 @@
"kc.interpolation.dominant": "Доминантный",
"kc.smoothing": "Сглаживание:",
"kc.smoothing.hint": "Временное смешивание между извлечениями (0=нет, 1=полное)",
- "kc.rectangles": "Цветовые Прямоугольники",
- "kc.rectangles.hint": "Определите именованные прямоугольники в относительных координатах (0.0–1.0) на захваченном изображении",
- "kc.rect.name": "Имя",
- "kc.rect.x": "X",
- "kc.rect.y": "Y",
- "kc.rect.width": "Ш",
- "kc.rect.height": "В",
- "kc.rect.add": "Добавить Прямоугольник",
- "kc.rect.remove": "Удалить",
- "kc.rect.empty": "Прямоугольники не определены. Добавьте хотя бы один для извлечения цветов.",
+ "kc.pattern_template": "Шаблон Паттерна:",
+ "kc.pattern_template.hint": "Выберите шаблон прямоугольников для извлечения цветов",
+ "kc.pattern_template.none": "-- Выберите шаблон паттерна --",
"kc.created": "Цель ключевых цветов успешно создана",
"kc.updated": "Цель ключевых цветов успешно обновлена",
"kc.deleted": "Цель ключевых цветов успешно удалена",
"kc.delete.confirm": "Вы уверены, что хотите удалить эту цель ключевых цветов?",
- "kc.error.no_rectangles": "Пожалуйста, добавьте хотя бы один прямоугольник",
+ "kc.error.no_pattern": "Пожалуйста, выберите шаблон паттерна",
"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": "Удалить Выбранный"
}
diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css
index aa2fa23..cfb7fb9 100644
--- a/server/src/wled_controller/static/style.css
+++ b/server/src/wled_controller/static/style.css
@@ -2706,3 +2706,157 @@ input:-webkit-autofill:focus {
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;
+}
+
diff --git a/server/src/wled_controller/storage/__init__.py b/server/src/wled_controller/storage/__init__.py
index 4672657..dda83b6 100644
--- a/server/src/wled_controller/storage/__init__.py
+++ b/server/src/wled_controller/storage/__init__.py
@@ -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"]
diff --git a/server/src/wled_controller/storage/key_colors_picture_target.py b/server/src/wled_controller/storage/key_colors_picture_target.py
index 19bb956..037b91c 100644
--- a/server/src/wled_controller/storage/key_colors_picture_target.py
+++ b/server/src/wled_controller/storage/key_colors_picture_target.py
@@ -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", ""),
)
diff --git a/server/src/wled_controller/storage/pattern_template.py b/server/src/wled_controller/storage/pattern_template.py
new file mode 100644
index 0000000..30543df
--- /dev/null
+++ b/server/src/wled_controller/storage/pattern_template.py
@@ -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"),
+ )
diff --git a/server/src/wled_controller/storage/pattern_template_store.py b/server/src/wled_controller/storage/pattern_template_store.py
new file mode 100644
index 0000000..b7b46f1
--- /dev/null
+++ b/server/src/wled_controller/storage/pattern_template_store.py
@@ -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