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

@@ -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)

View File

@@ -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

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

View File

@@ -365,23 +365,30 @@ 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
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 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

View File

@@ -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,
)

View File

@@ -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",

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

View File

@@ -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")

View File

@@ -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):

View File

@@ -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):

View File

@@ -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))

View File

@@ -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,
)

View File

@@ -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 = `<div class="stream-tab-bar">${subTabs.map(tab =>
@@ -4069,12 +4078,21 @@ async function loadTargetsTab() {
<div class="subtab-section">
<h3 class="subtab-section-header">${t('targets.section.key_colors')}</h3>
<div class="devices-grid">
${kcTargets.map(target => createKCTargetCard(target, sourceMap)).join('')}
${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
<div class="template-card add-template-card" onclick="showKCEditor()">
<div class="add-template-icon">+</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>`;
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) {
</div>
<div class="stream-card-props">
<span class="stream-card-prop" title="${t('kc.source')}">📺 ${escapeHtml(sourceName)}</span>
<span class="stream-card-prop" title="${t('kc.rectangles')}"> ${rectCount} rect${rectCount !== 1 ? 's' : ''}</span>
<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 class="card-content">
<div id="kc-swatches-${target.id}" class="kc-color-swatches">
@@ -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 = `<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')}">&#x2715;</button>
</div>
`).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 = `<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) {
// 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) {
</div>
`).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')}">&#x2715;</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')}">&#x2715;</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');
}
}

View File

@@ -378,14 +378,11 @@
<div class="form-group">
<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>
</div>
<small class="input-hint" style="display:none" data-i18n="kc.rectangles.hint">Define named rectangles in relative coordinates (0.0-1.0) on the captured image</small>
<div id="kc-rect-list" class="kc-rect-list"></div>
<button type="button" class="btn btn-secondary kc-add-rect-btn" onclick="addKCRectangle()">
+ <span data-i18n="kc.rect.add">Add Rectangle</span>
</button>
<small class="input-hint" style="display:none" data-i18n="kc.pattern_template.hint">Select the rectangle pattern to use for color extraction</small>
<select id="kc-editor-pattern-template"></select>
</div>
<div id="kc-editor-error" class="error-message" style="display: none;"></div>
@@ -398,6 +395,67 @@
</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">&#x2715;</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">&#x2715;</button>
<button class="btn btn-icon btn-primary" onclick="savePatternTemplate()" title="Save">&#x2713;</button>
</div>
</div>
</div>
<!-- Login Modal -->
<div id="api-key-modal" class="modal">
<div class="modal-content">

View File

@@ -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.01.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"
}

View File

@@ -360,21 +360,41 @@
"kc.interpolation.dominant": "Доминантный",
"kc.smoothing": "Сглаживание:",
"kc.smoothing.hint": "Временное смешивание между извлечениями (0=нет, 1=полное)",
"kc.rectangles": "Цветовые Прямоугольники",
"kc.rectangles.hint": "Определите именованные прямоугольники в относительных координатах (0.01.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": "Удалить Выбранный"
}

View File

@@ -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;
}

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