diff --git a/server/src/wled_controller/api/routes/_test_helpers.py b/server/src/wled_controller/api/routes/_test_helpers.py new file mode 100644 index 0000000..8a55f60 --- /dev/null +++ b/server/src/wled_controller/api/routes/_test_helpers.py @@ -0,0 +1,215 @@ +"""Shared helpers for WebSocket-based capture test endpoints.""" + +import asyncio +import base64 +import io +import secrets +import threading +import time +from typing import Callable, List, Optional + +import numpy as np +from PIL import Image +from starlette.websockets import WebSocket + +from wled_controller.config import get_config +from wled_controller.core.filters import FilterRegistry, ImagePool +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +PREVIEW_INTERVAL = 0.1 # seconds between intermediate thumbnail sends +PREVIEW_MAX_WIDTH = 640 # px for intermediate thumbnails +FINAL_THUMBNAIL_WIDTH = 640 # px for the final thumbnail +FINAL_JPEG_QUALITY = 90 +PREVIEW_JPEG_QUALITY = 70 + + +def authenticate_ws_token(token: str) -> bool: + """Check a WebSocket query-param token against configured API keys.""" + cfg = get_config() + if token and cfg.auth.api_keys: + for _label, api_key in cfg.auth.api_keys.items(): + if secrets.compare_digest(token, api_key): + return True + return False + + +def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str: + """Encode a PIL image as a JPEG base64 data URI.""" + buf = io.BytesIO() + pil_image.save(buf, format="JPEG", quality=quality) + buf.seek(0) + b64 = base64.b64encode(buf.getvalue()).decode("utf-8") + return f"data:image/jpeg;base64,{b64}" + + +def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image: + """Create a thumbnail copy of the image, preserving aspect ratio.""" + thumb = pil_image.copy() + aspect = pil_image.height / pil_image.width + thumb.thumbnail((max_width, int(max_width * aspect)), Image.Resampling.LANCZOS) + return thumb + + +def _apply_pp_filters(pil_image: Image.Image, flat_filters: list) -> Image.Image: + """Apply postprocessing filter instances to a PIL image.""" + if not flat_filters: + return pil_image + pool = ImagePool() + arr = np.array(pil_image) + for fi in flat_filters: + f = FilterRegistry.create_instance(fi.filter_id, fi.options) + result = f.process_image(arr, pool) + if result is not None: + arr = result + return Image.fromarray(arr) + + +async def stream_capture_test( + websocket: WebSocket, + engine_factory: Callable, + duration: float, + pp_filters: Optional[list] = None, + preview_width: Optional[int] = None, +) -> None: + """Run a capture test, streaming intermediate thumbnails and a final full-res frame. + + The engine is created and used entirely within a background thread to avoid + thread-affinity issues (e.g. MSS uses thread-local state). + + Args: + websocket: Accepted WebSocket connection. + engine_factory: Zero-arg callable that returns an initialized engine stream + (with .capture_frame() and .cleanup() methods). Called inside the + capture thread so thread-local resources work correctly. + duration: Test duration in seconds. + pp_filters: Optional list of resolved filter instances to apply to frames. + """ + thumb_width = preview_width or PREVIEW_MAX_WIDTH + + # Shared state between capture thread and async loop + latest_frame = None # PIL Image (converted from numpy) + frame_count = 0 + total_capture_time = 0.0 + stop_event = threading.Event() + done_event = threading.Event() + init_error = None # set if engine_factory fails + + def _capture_loop(): + nonlocal latest_frame, frame_count, total_capture_time, init_error + stream = None + try: + stream = engine_factory() + start = time.perf_counter() + end = start + duration + while time.perf_counter() < end and not stop_event.is_set(): + t0 = time.perf_counter() + capture = stream.capture_frame() + t1 = time.perf_counter() + if capture is None: + time.sleep(0.005) + continue + total_capture_time += t1 - t0 + frame_count += 1 + # Convert numpy → PIL once in the capture thread + if isinstance(capture.image, np.ndarray): + latest_frame = Image.fromarray(capture.image) + else: + latest_frame = capture.image + except Exception as e: + init_error = str(e) + logger.error(f"Capture thread error: {e}") + finally: + if stream: + try: + stream.cleanup() + except Exception: + pass + done_event.set() + + # Start capture in background thread + loop = asyncio.get_event_loop() + capture_future = loop.run_in_executor(None, _capture_loop) + + start_time = time.perf_counter() + last_sent_frame = None + + try: + # Stream intermediate previews + while not done_event.is_set(): + await asyncio.sleep(PREVIEW_INTERVAL) + + # Check for init error + if init_error: + await websocket.send_json({"type": "error", "detail": init_error}) + return + + frame = latest_frame + if frame is not None and frame is not last_sent_frame: + last_sent_frame = frame + elapsed = time.perf_counter() - start_time + fc = frame_count + tc = total_capture_time + # Encode preview thumbnail (small + fast) + thumb = _make_thumbnail(frame, thumb_width) + if pp_filters: + thumb = _apply_pp_filters(thumb, pp_filters) + thumb_uri = _encode_jpeg(thumb, PREVIEW_JPEG_QUALITY) + fps = fc / elapsed if elapsed > 0 else 0 + avg_ms = (tc / fc * 1000) if fc > 0 else 0 + await websocket.send_json({ + "type": "frame", + "thumbnail": thumb_uri, + "frame_count": fc, + "elapsed_s": round(elapsed, 2), + "fps": round(fps, 1), + "avg_capture_ms": round(avg_ms, 1), + }) + + # Wait for capture thread to fully finish + await capture_future + + # Check for errors + if init_error: + await websocket.send_json({"type": "error", "detail": init_error}) + return + + # Send final result + final_frame = latest_frame + if final_frame is None: + await websocket.send_json({"type": "error", "detail": "No frames captured"}) + return + + elapsed = time.perf_counter() - start_time + fc = frame_count + tc = total_capture_time + fps = fc / elapsed if elapsed > 0 else 0 + avg_ms = (tc / fc * 1000) if fc > 0 else 0 + w, h = final_frame.size + + # Apply PP filters to final images + if pp_filters: + final_frame = _apply_pp_filters(final_frame, pp_filters) + + full_uri = _encode_jpeg(final_frame, FINAL_JPEG_QUALITY) + thumb = _make_thumbnail(final_frame, FINAL_THUMBNAIL_WIDTH) + thumb_uri = _encode_jpeg(thumb, 85) + + await websocket.send_json({ + "type": "result", + "full_image": full_uri, + "thumbnail": thumb_uri, + "width": w, + "height": h, + "frame_count": fc, + "elapsed_s": round(elapsed, 2), + "fps": round(fps, 1), + "avg_capture_ms": round(avg_ms, 1), + }) + + except Exception: + # WebSocket disconnect or send error — signal capture thread to stop + stop_event.set() + await capture_future + raise diff --git a/server/src/wled_controller/api/routes/picture_sources.py b/server/src/wled_controller/api/routes/picture_sources.py index c2daa16..e3e59a6 100644 --- a/server/src/wled_controller/api/routes/picture_sources.py +++ b/server/src/wled_controller/api/routes/picture_sources.py @@ -7,7 +7,7 @@ import time import httpx import numpy as np from PIL import Image -from fastapi import APIRouter, HTTPException, Depends, Query +from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from fastapi.responses import Response from wled_controller.api.auth import AuthRequired @@ -467,3 +467,100 @@ async def test_picture_source( stream.cleanup() except Exception as e: logger.error(f"Error cleaning up test stream: {e}") + + +# ===== REAL-TIME PICTURE SOURCE TEST WEBSOCKET ===== + + +@router.websocket("/api/v1/picture-sources/{stream_id}/test/ws") +async def test_picture_source_ws( + websocket: WebSocket, + stream_id: str, + token: str = Query(""), + duration: float = Query(5.0), + preview_width: int = Query(0), +): + """WebSocket for picture source test with intermediate frame previews.""" + from wled_controller.api.routes._test_helpers import ( + authenticate_ws_token, + stream_capture_test, + ) + from wled_controller.api.dependencies import ( + get_picture_source_store as _get_ps_store, + get_template_store as _get_t_store, + get_pp_template_store as _get_pp_store, + ) + + if not authenticate_ws_token(token): + await websocket.close(code=4001, reason="Unauthorized") + return + + store = _get_ps_store() + template_store = _get_t_store() + pp_store = _get_pp_store() + + # Resolve stream chain + try: + chain = store.resolve_stream_chain(stream_id) + except ValueError as e: + await websocket.close(code=4004, reason=str(e)) + return + + raw_stream = chain["raw_stream"] + + # Static images don't benefit from streaming — reject gracefully + if isinstance(raw_stream, StaticImagePictureSource): + await websocket.close(code=4003, reason="Static image streams don't support live test") + return + + if not isinstance(raw_stream, ScreenCapturePictureSource): + await websocket.close(code=4003, reason="Unsupported stream type for live test") + return + + # Create capture engine + try: + capture_template = template_store.get_template(raw_stream.capture_template_id) + except ValueError as e: + await websocket.close(code=4004, reason=str(e)) + return + + if capture_template.engine_type not in EngineRegistry.get_available_engines(): + await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available") + return + + # Resolve postprocessing filters (if any) + pp_filters = None + pp_template_ids = chain.get("postprocessing_template_ids", []) + if pp_template_ids: + try: + pp_template = pp_store.get_template(pp_template_ids[0]) + pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None + except ValueError: + pass + + # Engine factory — creates + initializes engine inside the capture thread + # to avoid thread-affinity issues (e.g. MSS uses thread-local state) + _engine_type = capture_template.engine_type + _display_index = raw_stream.display_index + _engine_config = capture_template.engine_config + + def engine_factory(): + s = EngineRegistry.create_stream(_engine_type, _display_index, _engine_config) + s.initialize() + return s + + await websocket.accept() + logger.info(f"Picture source test WS connected for {stream_id} ({duration}s)") + + try: + await stream_capture_test( + websocket, engine_factory, duration, + pp_filters=pp_filters, + preview_width=preview_width or None, + ) + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"Picture source test WS error for {stream_id}: {e}") + finally: + logger.info(f"Picture source test WS disconnected for {stream_id}") diff --git a/server/src/wled_controller/api/routes/postprocessing.py b/server/src/wled_controller/api/routes/postprocessing.py index f189e51..e5af21e 100644 --- a/server/src/wled_controller/api/routes/postprocessing.py +++ b/server/src/wled_controller/api/routes/postprocessing.py @@ -7,7 +7,7 @@ import time import httpx import numpy as np from PIL import Image -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( @@ -330,3 +330,104 @@ async def test_pp_template( stream.cleanup() except Exception: pass + + +# ===== REAL-TIME PP TEMPLATE TEST WEBSOCKET ===== + + +@router.websocket("/api/v1/postprocessing-templates/{template_id}/test/ws") +async def test_pp_template_ws( + websocket: WebSocket, + template_id: str, + token: str = Query(""), + duration: float = Query(5.0), + source_stream_id: str = Query(""), + preview_width: int = Query(0), +): + """WebSocket for PP template test with intermediate frame previews.""" + from wled_controller.api.routes._test_helpers import ( + authenticate_ws_token, + stream_capture_test, + ) + from wled_controller.api.dependencies import ( + get_picture_source_store as _get_ps_store, + get_template_store as _get_t_store, + get_pp_template_store as _get_pp_store, + ) + + if not authenticate_ws_token(token): + await websocket.close(code=4001, reason="Unauthorized") + return + + if not source_stream_id: + await websocket.close(code=4003, reason="source_stream_id is required") + return + + pp_store = _get_pp_store() + stream_store = _get_ps_store() + template_store = _get_t_store() + + # Get PP template + try: + pp_template = pp_store.get_template(template_id) + except ValueError as e: + await websocket.close(code=4004, reason=str(e)) + return + + # Resolve source stream chain + try: + chain = stream_store.resolve_stream_chain(source_stream_id) + except ValueError as e: + await websocket.close(code=4004, reason=str(e)) + return + + raw_stream = chain["raw_stream"] + + if isinstance(raw_stream, StaticImagePictureSource): + await websocket.close(code=4003, reason="Static image streams don't support live test") + return + + if not isinstance(raw_stream, ScreenCapturePictureSource): + await websocket.close(code=4003, reason="Unsupported stream type for live test") + return + + # Create capture engine + try: + capture_template = template_store.get_template(raw_stream.capture_template_id) + except ValueError as e: + await websocket.close(code=4004, reason=str(e)) + return + + if capture_template.engine_type not in EngineRegistry.get_available_engines(): + await websocket.close(code=4003, reason=f"Engine '{capture_template.engine_type}' not available") + return + + # Resolve PP filters + pp_filters = pp_store.resolve_filter_instances(pp_template.filters) or None + + # Engine factory — creates + initializes engine inside the capture thread + # to avoid thread-affinity issues (e.g. MSS uses thread-local state) + _engine_type = capture_template.engine_type + _display_index = raw_stream.display_index + _engine_config = capture_template.engine_config + + def engine_factory(): + s = EngineRegistry.create_stream(_engine_type, _display_index, _engine_config) + s.initialize() + return s + + await websocket.accept() + logger.info(f"PP template test WS connected for {template_id} ({duration}s)") + + try: + await stream_capture_test( + websocket, engine_factory, duration, + pp_filters=pp_filters, + preview_width=preview_width or None, + ) + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"PP template test WS error for {template_id}: {e}") + finally: + logger.info(f"PP template test WS disconnected for {template_id}") diff --git a/server/src/wled_controller/api/routes/templates.py b/server/src/wled_controller/api/routes/templates.py index ae3c0b8..160191e 100644 --- a/server/src/wled_controller/api/routes/templates.py +++ b/server/src/wled_controller/api/routes/templates.py @@ -6,7 +6,7 @@ import time import numpy as np from PIL import Image -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect from wled_controller.api.auth import AuthRequired from wled_controller.api.dependencies import ( @@ -365,6 +365,68 @@ def test_template( logger.error(f"Error cleaning up test stream: {e}") +# ===== REAL-TIME CAPTURE TEMPLATE TEST WEBSOCKET ===== + + +@router.websocket("/api/v1/capture-templates/test/ws") +async def test_template_ws( + websocket: WebSocket, + token: str = Query(""), +): + """WebSocket for capture template test with intermediate frame previews. + + Config is sent as the first client message (JSON with engine_type, + engine_config, display_index, capture_duration). + """ + from wled_controller.api.routes._test_helpers import ( + authenticate_ws_token, + stream_capture_test, + ) + + if not authenticate_ws_token(token): + await websocket.close(code=4001, reason="Unauthorized") + return + + await websocket.accept() + + # Read config from first client message + try: + config = await websocket.receive_json() + except Exception as e: + await websocket.send_json({"type": "error", "detail": f"Expected JSON config: {e}"}) + await websocket.close(code=4003) + return + + engine_type = config.get("engine_type", "") + engine_config = config.get("engine_config", {}) + display_index = config.get("display_index", 0) + duration = float(config.get("capture_duration", 5.0)) + pw = int(config.get("preview_width", 0)) or None + + if engine_type not in EngineRegistry.get_available_engines(): + await websocket.send_json({"type": "error", "detail": f"Engine '{engine_type}' not available"}) + await websocket.close(code=4003) + return + + # Engine factory — creates + initializes engine inside the capture thread + # to avoid thread-affinity issues (e.g. MSS uses thread-local state) + def engine_factory(): + s = EngineRegistry.create_stream(engine_type, display_index, engine_config) + s.initialize() + return s + + logger.info(f"Capture template test WS connected ({engine_type}, display {display_index}, {duration}s)") + + try: + await stream_capture_test(websocket, engine_factory, duration, preview_width=pw) + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"Capture template test WS error: {e}") + finally: + logger.info("Capture template test WS disconnected") + + # ===== FILTER TYPE ENDPOINTS ===== @router.get("/api/v1/filters", response_model=FilterTypeListResponse, tags=["Filters"]) diff --git a/server/src/wled_controller/static/css/components.css b/server/src/wled_controller/static/css/components.css index 6532913..070b786 100644 --- a/server/src/wled_controller/static/css/components.css +++ b/server/src/wled_controller/static/css/components.css @@ -313,6 +313,21 @@ input:-webkit-autofill:focus { color: white; } +.overlay-preview-img { + max-width: 80vw; + max-height: 50vh; + border-radius: 8px; + margin-top: 16px; + object-fit: contain; +} + +.overlay-preview-stats { + color: rgba(255, 255, 255, 0.7); + font-size: 0.85rem; + margin-top: 8px; + font-variant-numeric: tabular-nums; +} + .toast { position: fixed; bottom: 40px; diff --git a/server/src/wled_controller/static/js/core/ui.js b/server/src/wled_controller/static/js/core/ui.js index 89fb656..d6dda58 100644 --- a/server/src/wled_controller/static/js/core/ui.js +++ b/server/src/wled_controller/static/js/core/ui.js @@ -255,8 +255,22 @@ export function showOverlaySpinner(text, duration = 0) { spinnerText.className = 'spinner-text'; spinnerText.textContent = text; + // Preview image (hidden until updateOverlayPreview is called) + const previewImg = document.createElement('img'); + previewImg.id = 'overlay-preview-img'; + previewImg.className = 'overlay-preview-img'; + previewImg.style.display = 'none'; + + // Preview stats (hidden until updateOverlayPreview is called) + const previewStats = document.createElement('div'); + previewStats.id = 'overlay-preview-stats'; + previewStats.className = 'overlay-preview-stats'; + previewStats.style.display = 'none'; + overlay.appendChild(progressContainer); overlay.appendChild(spinnerText); + overlay.appendChild(previewImg); + overlay.appendChild(previewStats); document.body.appendChild(overlay); if (duration > 0) { @@ -293,6 +307,24 @@ export function hideOverlaySpinner() { if (overlay) overlay.remove(); } +/** + * Update the overlay spinner with a live preview thumbnail and stats. + * Call this while the spinner is open to show intermediate test frames. + */ +export function updateOverlayPreview(thumbnailSrc, stats) { + const img = document.getElementById('overlay-preview-img'); + const statsEl = document.getElementById('overlay-preview-stats'); + if (!img || !statsEl) return; + if (thumbnailSrc) { + img.src = thumbnailSrc; + img.style.display = ''; + } + if (stats) { + statsEl.textContent = `${t('test.frames')}: ${stats.frame_count} | ${t('test.fps')}: ${stats.fps} | ${t('test.avg_capture')}: ${stats.avg_capture_ms}ms`; + statsEl.style.display = ''; + } +} + /** Toggle the thin loading bar on a tab panel during data refresh. * Delays showing the bar by 400ms so quick loads never flash it. */ const _refreshTimers = {}; diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index b4dc14d..9a2db40 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -30,7 +30,7 @@ import { import { API_BASE, getHeaders, fetchWithAuth, escapeHtml } from '../core/api.js'; import { t } from '../core/i18n.js'; import { Modal } from '../core/modal.js'; -import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, setTabRefreshing } from '../core/ui.js'; +import { showToast, showConfirm, openLightbox, openFullImageLightbox, showOverlaySpinner, hideOverlaySpinner, updateOverlayPreview, setTabRefreshing } from '../core/ui.js'; import { openDisplayPicker, formatDisplayLabel } from './displays.js'; import { CardSection } from '../core/card-sections.js'; import { updateSubTabHash } from './tabs.js'; @@ -415,7 +415,7 @@ async function loadDisplaysForTest() { } } -export async function runTemplateTest() { +export function runTemplateTest() { if (!window.currentTestingTemplate) { showToast(t('templates.test.error.no_engine'), 'error'); return; @@ -430,57 +430,137 @@ export async function runTemplateTest() { } const template = window.currentTestingTemplate; - showOverlaySpinner(t('templates.test.running'), captureDuration); - const signal = window._overlayAbortController?.signal; + localStorage.setItem('lastTestDisplayIndex', displayIndex); - try { - const response = await fetchWithAuth('/capture-templates/test', { - method: 'POST', - body: JSON.stringify({ - engine_type: template.engine_type, - engine_config: template.engine_config, - display_index: parseInt(displayIndex), - capture_duration: captureDuration - }), - signal - }); - - if (!response.ok) { - const error = await response.json(); - throw new Error(error.detail || error.message || 'Test failed'); - } - - const result = await response.json(); - localStorage.setItem('lastTestDisplayIndex', displayIndex); - displayTestResults(result); - } catch (error) { - if (error.name === 'AbortError') return; - console.error('Error running test:', error); - hideOverlaySpinner(); - showToast(t('templates.test.error.failed'), 'error'); - } + const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2)); + _runTestViaWS( + '/capture-templates/test/ws', + {}, + { + engine_type: template.engine_type, + engine_config: template.engine_config, + display_index: parseInt(displayIndex), + capture_duration: captureDuration, + preview_width: previewWidth, + }, + captureDuration, + ); } function buildTestStatsHtml(result) { - const p = result.performance; - const res = `${result.full_capture.width}x${result.full_capture.height}`; + // Support both REST format (nested) and WS format (flat) + const p = result.performance || result; + const duration = p.capture_duration_s ?? p.elapsed_s ?? 0; + const frameCount = p.frame_count ?? 0; + const fps = p.actual_fps ?? p.fps ?? 0; + const avgMs = p.avg_capture_time_ms ?? p.avg_capture_ms ?? 0; + const w = result.full_capture?.width ?? result.width ?? 0; + const h = result.full_capture?.height ?? result.height ?? 0; + const res = `${w}x${h}`; + let html = ` -