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() {

${t('targets.section.key_colors')}

- ${kcTargets.map(target => createKCTargetCard(target, sourceMap)).join('')} + ${kcTargets.map(target => createKCTargetCard(target, sourceMap, patternTemplateMap)).join('')}
+
+
+

${t('targets.section.pattern_templates')}

+
+ ${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 ` +
+ +
+ 📄 ${escapeHtml(pt.name)} +
+ ${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 @@
+ + +