diff --git a/server/src/wled_controller/api/routes/picture_targets.py b/server/src/wled_controller/api/routes/picture_targets.py index 3d72b9a..ddee169 100644 --- a/server/src/wled_controller/api/routes/picture_targets.py +++ b/server/src/wled_controller/api/routes/picture_targets.py @@ -1,17 +1,27 @@ """Picture target routes: CRUD, processing control, settings, state, metrics.""" +import base64 +import io import secrets +import time +import numpy as np from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect +from PIL import Image from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( get_device_store, + get_pattern_template_store, + get_picture_source_store, get_picture_target_store, get_processor_manager, + get_template_store, ) from wled_controller.api.schemas.picture_targets import ( ExtractedColorResponse, + KCTestRectangleResponse, + KCTestResponse, KeyColorsResponse, KeyColorsSettingsSchema, PictureTargetCreate, @@ -23,8 +33,18 @@ from wled_controller.api.schemas.picture_targets import ( TargetProcessingState, ) from wled_controller.config import config +from wled_controller.core.capture_engines import EngineRegistry from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings +from wled_controller.core.screen_capture import ( + calculate_average_color, + calculate_dominant_color, + calculate_median_color, +) from wled_controller.storage import DeviceStore +from wled_controller.storage.pattern_template_store import PatternTemplateStore +from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource +from wled_controller.storage.picture_source_store import PictureSourceStore +from wled_controller.storage.template_store import TemplateStore from wled_controller.storage.wled_picture_target import WledPictureTarget from wled_controller.storage.key_colors_picture_target import ( KeyColorsSettings, @@ -531,6 +551,182 @@ async def get_target_colors( raise HTTPException(status_code=404, detail=str(e)) +@router.post("/api/v1/picture-targets/{target_id}/test", response_model=KCTestResponse, tags=["Key Colors"]) +async def test_kc_target( + target_id: str, + _auth: AuthRequired, + target_store: PictureTargetStore = Depends(get_picture_target_store), + source_store: PictureSourceStore = Depends(get_picture_source_store), + template_store: TemplateStore = Depends(get_template_store), + pattern_store: PatternTemplateStore = Depends(get_pattern_template_store), + processor_manager: ProcessorManager = Depends(get_processor_manager), + device_store: DeviceStore = Depends(get_device_store), +): + """Test a key-colors target: capture a frame, extract colors from each rectangle.""" + import httpx + + stream = None + try: + # 1. Load and validate KC target + try: + target = target_store.get_target(target_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + if not isinstance(target, KeyColorsPictureTarget): + raise HTTPException(status_code=400, detail="Target is not a key_colors target") + + settings = target.settings + + # 2. Resolve pattern template + if not settings.pattern_template_id: + raise HTTPException(status_code=400, detail="No pattern template configured") + + try: + pattern_tmpl = pattern_store.get_template(settings.pattern_template_id) + except ValueError: + raise HTTPException(status_code=400, detail=f"Pattern template not found: {settings.pattern_template_id}") + + rectangles = pattern_tmpl.rectangles + if not rectangles: + raise HTTPException(status_code=400, detail="Pattern template has no rectangles") + + # 3. Resolve picture source and capture a frame + if not target.picture_source_id: + raise HTTPException(status_code=400, detail="No picture source configured") + + try: + chain = source_store.resolve_stream_chain(target.picture_source_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + raw_stream = chain["raw_stream"] + + if isinstance(raw_stream, StaticImagePictureSource): + source = raw_stream.image_source + if source.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(source) + resp.raise_for_status() + pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") + else: + from pathlib import Path + path = Path(source) + if not path.exists(): + raise HTTPException(status_code=400, detail=f"Image file not found: {source}") + pil_image = Image.open(path).convert("RGB") + + elif isinstance(raw_stream, ScreenCapturePictureSource): + try: + capture_template = template_store.get_template(raw_stream.capture_template_id) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Capture template not found: {raw_stream.capture_template_id}", + ) + + display_index = raw_stream.display_index + + if capture_template.engine_type not in EngineRegistry.get_available_engines(): + raise HTTPException( + status_code=400, + detail=f"Engine '{capture_template.engine_type}' is not available on this system", + ) + + locked_device_id = processor_manager.get_display_lock_info(display_index) + if locked_device_id: + try: + device = device_store.get_device(locked_device_id) + device_name = device.name + except Exception: + device_name = locked_device_id + raise HTTPException( + status_code=409, + detail=f"Display {display_index} is currently being captured by device '{device_name}'. " + f"Please stop the device processing before testing.", + ) + + stream = EngineRegistry.create_stream( + capture_template.engine_type, display_index, capture_template.engine_config + ) + stream.initialize() + + screen_capture = stream.capture_frame() + if screen_capture is None: + raise RuntimeError("No frame captured") + + if isinstance(screen_capture.image, np.ndarray): + pil_image = Image.fromarray(screen_capture.image) + else: + raise ValueError("Unexpected image format from engine") + else: + raise HTTPException(status_code=400, detail="Unsupported picture source type") + + # 4. Extract colors from each rectangle + img_array = np.array(pil_image) + h, w = img_array.shape[:2] + + calc_fns = { + "average": calculate_average_color, + "median": calculate_median_color, + "dominant": calculate_dominant_color, + } + calc_fn = calc_fns.get(settings.interpolation_mode, calculate_average_color) + + result_rects = [] + for rect in rectangles: + px_x = max(0, int(rect.x * w)) + px_y = max(0, int(rect.y * h)) + px_w = max(1, int(rect.width * w)) + px_h = max(1, int(rect.height * h)) + px_x = min(px_x, w - 1) + px_y = min(px_y, h - 1) + px_w = min(px_w, w - px_x) + px_h = min(px_h, h - px_y) + + sub_img = img_array[px_y:px_y + px_h, px_x:px_x + px_w] + r, g, b = calc_fn(sub_img) + + result_rects.append(KCTestRectangleResponse( + name=rect.name, + x=rect.x, + y=rect.y, + width=rect.width, + height=rect.height, + color=ExtractedColorResponse(r=r, g=g, b=b, hex=f"#{r:02x}{g:02x}{b:02x}"), + )) + + # 5. Encode frame as base64 JPEG + full_buffer = io.BytesIO() + pil_image.save(full_buffer, format='JPEG', quality=90) + full_buffer.seek(0) + full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') + image_data_uri = f"data:image/jpeg;base64,{full_b64}" + + return KCTestResponse( + image=image_data_uri, + rectangles=result_rects, + interpolation_mode=settings.interpolation_mode, + pattern_template_name=pattern_tmpl.name, + ) + + except HTTPException: + raise + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except RuntimeError as e: + raise HTTPException(status_code=500, detail=f"Capture error: {str(e)}") + except Exception as e: + logger.error(f"Failed to test KC target: {e}", exc_info=True) + raise HTTPException(status_code=500, detail=str(e)) + finally: + if stream: + try: + stream.cleanup() + except Exception as e: + logger.error(f"Error cleaning up test stream: {e}") + + @router.websocket("/api/v1/picture-targets/{target_id}/ws") async def target_colors_ws( websocket: WebSocket, diff --git a/server/src/wled_controller/api/schemas/picture_targets.py b/server/src/wled_controller/api/schemas/picture_targets.py index 9769271..729b53c 100644 --- a/server/src/wled_controller/api/schemas/picture_targets.py +++ b/server/src/wled_controller/api/schemas/picture_targets.py @@ -151,3 +151,23 @@ class TargetMetricsResponse(BaseModel): errors_count: int = Field(description="Total error count") last_error: Optional[str] = Field(None, description="Last error message") last_update: Optional[datetime] = Field(None, description="Last update timestamp") + + +class KCTestRectangleResponse(BaseModel): + """A rectangle with its extracted color from a KC test.""" + + name: str = Field(description="Rectangle name") + x: float = Field(description="Left edge (0.0-1.0)") + y: float = Field(description="Top edge (0.0-1.0)") + width: float = Field(description="Width (0.0-1.0)") + height: float = Field(description="Height (0.0-1.0)") + color: ExtractedColorResponse = Field(description="Extracted color for this rectangle") + + +class KCTestResponse(BaseModel): + """Response from testing a KC target.""" + + image: str = Field(description="Base64 data URI of the captured frame") + rectangles: List[KCTestRectangleResponse] = Field(description="Rectangles with extracted colors") + interpolation_mode: str = Field(description="Color extraction mode used") + pattern_template_name: str = Field(description="Pattern template name") diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index fe927f4..a073e70 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -85,7 +85,10 @@ function closeLightbox(event) { // Revoke blob URL if one was used if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src); img.src = ''; + img.style.display = ''; document.getElementById('lightbox-stats').style.display = 'none'; + const spinner = lightbox.querySelector('.lightbox-spinner'); + if (spinner) spinner.style.display = 'none'; unlockBody(); } @@ -4152,7 +4155,6 @@ function createTargetCard(target, deviceMap, sourceMap) {
${escapeHtml(target.name)} - ${target.target_type.toUpperCase()} ${isProcessing ? `${t('device.status.processing')}` : ''}
@@ -4295,7 +4297,6 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) {
${escapeHtml(target.name)} - KEY COLORS ${isProcessing ? `${t('targets.status.processing')}` : ''}
@@ -4339,6 +4340,9 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) { ▶️ `} + @@ -4347,6 +4351,131 @@ function createKCTargetCard(target, sourceMap, patternTemplateMap) { `; } +// ===== KEY COLORS TEST ===== + +async function testKCTarget(targetId) { + // Show lightbox immediately with a spinner + const lightbox = document.getElementById('image-lightbox'); + const lbImg = document.getElementById('lightbox-image'); + const statsEl = document.getElementById('lightbox-stats'); + lbImg.style.display = 'none'; + lbImg.src = ''; + statsEl.style.display = 'none'; + + // Insert spinner if not already present + let spinner = lightbox.querySelector('.lightbox-spinner'); + if (!spinner) { + spinner = document.createElement('div'); + spinner.className = 'lightbox-spinner loading-spinner'; + lightbox.querySelector('.lightbox-content').prepend(spinner); + } + spinner.style.display = ''; + + lightbox.classList.add('active'); + lockBody(); + + try { + const response = await fetch(`${API_BASE}/picture-targets/${targetId}/test`, { + method: 'POST', + headers: getHeaders(), + }); + if (!response.ok) { + const err = await response.json().catch(() => ({})); + throw new Error(err.detail || response.statusText); + } + const result = await response.json(); + displayKCTestResults(result); + } catch (e) { + closeLightbox(); + showToast(t('kc.test.error') + ': ' + e.message, 'error'); + } +} + +function displayKCTestResults(result) { + const srcImg = new window.Image(); + srcImg.onload = () => { + const canvas = document.createElement('canvas'); + canvas.width = srcImg.width; + canvas.height = srcImg.height; + const ctx = canvas.getContext('2d'); + + // Draw captured frame + ctx.drawImage(srcImg, 0, 0); + + const w = srcImg.width; + const h = srcImg.height; + + // Draw each rectangle with extracted color overlay + result.rectangles.forEach((rect, i) => { + const px = rect.x * w; + const py = rect.y * h; + const pw = rect.width * w; + const ph = rect.height * h; + + const color = rect.color; + const borderColor = PATTERN_RECT_BORDERS[i % PATTERN_RECT_BORDERS.length]; + + // Semi-transparent fill with the extracted color + ctx.fillStyle = `rgba(${color.r}, ${color.g}, ${color.b}, 0.3)`; + ctx.fillRect(px, py, pw, ph); + + // Border using pattern colors for distinction + ctx.strokeStyle = borderColor; + ctx.lineWidth = 3; + ctx.strokeRect(px, py, pw, ph); + + // Color swatch in top-left corner of rect + const swatchSize = Math.max(16, Math.min(32, pw * 0.15)); + ctx.fillStyle = color.hex; + ctx.fillRect(px + 4, py + 4, swatchSize, swatchSize); + ctx.strokeStyle = '#fff'; + ctx.lineWidth = 1; + ctx.strokeRect(px + 4, py + 4, swatchSize, swatchSize); + + // Name label with shadow for readability + const fontSize = Math.max(12, Math.min(18, pw * 0.06)); + ctx.font = `bold ${fontSize}px sans-serif`; + const labelX = px + swatchSize + 10; + const labelY = py + 4 + swatchSize / 2 + fontSize / 3; + ctx.shadowColor = 'rgba(0,0,0,0.8)'; + ctx.shadowBlur = 4; + ctx.fillStyle = '#fff'; + ctx.fillText(rect.name, labelX, labelY); + + // Hex label below name + ctx.font = `${fontSize - 2}px monospace`; + ctx.fillText(color.hex, labelX, labelY + fontSize + 2); + ctx.shadowBlur = 0; + }); + + const dataUrl = canvas.toDataURL('image/jpeg', 0.92); + + // Build stats HTML + let statsHtml = `
`; + statsHtml += `${escapeHtml(result.pattern_template_name)} \u2022 ${escapeHtml(result.interpolation_mode)}`; + result.rectangles.forEach((rect) => { + const c = rect.color; + statsHtml += `
`; + statsHtml += `
`; + statsHtml += `${escapeHtml(rect.name)} ${c.hex}`; + statsHtml += `
`; + }); + statsHtml += `
`; + + // Hide spinner, show result in the already-open lightbox + const spinner = document.querySelector('.lightbox-spinner'); + if (spinner) spinner.style.display = 'none'; + + const lbImg = document.getElementById('lightbox-image'); + const statsEl = document.getElementById('lightbox-stats'); + lbImg.src = dataUrl; + lbImg.style.display = ''; + statsEl.innerHTML = statsHtml; + statsEl.style.display = ''; + }; + srcImg.src = result.image; +} + // ===== KEY COLORS EDITOR ===== let kcEditorInitialValues = {}; diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index f128521..e15b8bf 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -16,6 +16,7 @@
+ API @@ -515,8 +516,8 @@
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index b6246ab..1f1aa61 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -1,6 +1,7 @@ { "app.title": "LED Grab", "app.version": "Version:", + "app.api_docs": "API Documentation", "theme.toggle": "Toggle theme", "locale.change": "Change language", "auth.login": "Login", @@ -370,6 +371,8 @@ "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.test": "Test", + "kc.test.error": "Test failed", "targets.section.pattern_templates": "📄 Pattern Templates", "pattern.add": "📄 Add Pattern Template", "pattern.edit": "📄 Edit Pattern Template", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index bae3e26..f72cccd 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -1,6 +1,7 @@ { "app.title": "LED Grab", "app.version": "Версия:", + "app.api_docs": "Документация API", "theme.toggle": "Переключить тему", "locale.change": "Изменить язык", "auth.login": "Войти", @@ -370,6 +371,8 @@ "kc.error.no_pattern": "Пожалуйста, выберите шаблон паттерна", "kc.error.required": "Пожалуйста, заполните все обязательные поля", "kc.colors.none": "Цвета пока не извлечены", + "kc.test": "Тест", + "kc.test.error": "Ошибка теста", "targets.section.pattern_templates": "📄 Шаблоны Паттернов", "pattern.add": "📄 Добавить Шаблон Паттерна", "pattern.edit": "📄 Редактировать Шаблон Паттерна", diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index bcef9e1..444600b 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -91,6 +91,21 @@ h2 { gap: 15px; } +.header-link { + color: var(--text-secondary); + text-decoration: none; + font-size: 0.85rem; + font-weight: 500; + padding: 4px 8px; + border-radius: 4px; + transition: color 0.2s, background 0.2s; +} + +.header-link:hover { + color: var(--text-color); + background: var(--bg-secondary); +} + #server-version { font-size: 0.75rem; font-weight: 400;