From b8bfdac36b096a4e6acb7c053600b65e0ae1677b Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Thu, 26 Feb 2026 16:34:30 +0300 Subject: [PATCH] Add live preview streaming for capture tests via WebSocket Replace blocking REST-based capture tests with WebSocket endpoints that stream intermediate frame thumbnails at ~100ms intervals, giving real-time visual feedback during capture. Preview resolution adapts dynamically to the client viewport size and device pixel ratio. - New shared helper (_test_helpers.py) with engine_factory pattern to avoid MSS thread-affinity issues - WS endpoints for stream, capture template, and PP template tests - Enhanced overlay spinner with live preview image and stats - Frontend _runTestViaWS shared helper replaces three REST test runners Co-Authored-By: Claude Opus 4.6 --- .../api/routes/_test_helpers.py | 215 +++++++++++++++++ .../api/routes/picture_sources.py | 99 +++++++- .../api/routes/postprocessing.py | 103 +++++++- .../wled_controller/api/routes/templates.py | 64 ++++- .../wled_controller/static/css/components.css | 15 ++ .../src/wled_controller/static/js/core/ui.js | 32 +++ .../static/js/features/streams.js | 222 +++++++++++------- .../wled_controller/static/locales/en.json | 3 + .../wled_controller/static/locales/ru.json | 3 + .../wled_controller/static/locales/zh.json | 3 + 10 files changed, 669 insertions(+), 90 deletions(-) create mode 100644 server/src/wled_controller/api/routes/_test_helpers.py 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 = ` -
${t('templates.test.results.duration')}: ${p.capture_duration_s.toFixed(2)}s
-
${t('templates.test.results.frame_count')}: ${p.frame_count}
`; - if (p.frame_count > 1) { +
${t('templates.test.results.duration')}: ${Number(duration).toFixed(2)}s
+
${t('templates.test.results.frame_count')}: ${frameCount}
`; + if (frameCount > 1) { html += ` -
${t('templates.test.results.actual_fps')}: ${p.actual_fps.toFixed(1)}
-
${t('templates.test.results.avg_capture_time')}: ${p.avg_capture_time_ms.toFixed(1)}ms
`; +
${t('templates.test.results.actual_fps')}: ${Number(fps).toFixed(1)}
+
${t('templates.test.results.avg_capture_time')}: ${Number(avgMs).toFixed(1)}ms
`; } html += `
Resolution: ${res}
`; return html; } -function displayTestResults(result) { - hideOverlaySpinner(); - const fullImageSrc = result.full_capture.full_image || result.full_capture.image; - openLightbox(fullImageSrc, buildTestStatsHtml(result)); +// ===== Shared WebSocket test helper ===== + +/** + * Run a capture test via WebSocket, streaming intermediate previews into + * the overlay spinner and opening the lightbox with the final result. + * + * @param {string} wsPath Relative WS path (e.g. '/picture-sources/{id}/test/ws') + * @param {Object} queryParams Extra query params (duration, source_stream_id, etc.) + * @param {Object|null} firstMessage If non-null, sent as JSON after WS opens (for template test) + * @param {number} duration Test duration for overlay progress ring + */ +function _runTestViaWS(wsPath, queryParams = {}, firstMessage = null, duration = 5) { + const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:'; + // Dynamic preview resolution: 80% of viewport width, scaled by DPR, capped at 1920px + const previewWidth = Math.round(Math.min(window.innerWidth * 0.8, 1920) * Math.min(window.devicePixelRatio || 1, 2)); + const params = new URLSearchParams({ token: apiKey, preview_width: previewWidth, ...queryParams }); + const wsUrl = `${protocol}//${window.location.host}${API_BASE}${wsPath}?${params}`; + + showOverlaySpinner(t('streams.test.running'), duration); + + let gotResult = false; + let ws; + + try { + ws = new WebSocket(wsUrl); + } catch (e) { + hideOverlaySpinner(); + showToast(t('streams.test.error.failed') + ': ' + e.message, 'error'); + return; + } + + // Close WS when user cancels overlay + const patchCloseBtn = () => { + const closeBtn = document.querySelector('.overlay-spinner-close'); + if (closeBtn) { + const origHandler = closeBtn.onclick; + closeBtn.onclick = () => { + if (ws.readyState <= WebSocket.OPEN) ws.close(); + if (origHandler) origHandler(); + }; + } + }; + patchCloseBtn(); + + // Also close on ESC (overlay ESC handler calls hideOverlaySpinner which aborts) + const origAbort = window._overlayAbortController; + if (origAbort) { + origAbort.signal.addEventListener('abort', () => { + if (ws.readyState <= WebSocket.OPEN) ws.close(); + }, { once: true }); + } + + ws.onopen = () => { + if (firstMessage) { + ws.send(JSON.stringify(firstMessage)); + } + }; + + ws.onmessage = (event) => { + try { + const msg = JSON.parse(event.data); + if (msg.type === 'frame') { + updateOverlayPreview(msg.thumbnail, msg); + } else if (msg.type === 'result') { + gotResult = true; + hideOverlaySpinner(); + openLightbox(msg.full_image, buildTestStatsHtml(msg)); + ws.close(); + } else if (msg.type === 'error') { + hideOverlaySpinner(); + showToast(msg.detail || 'Test failed', 'error'); + ws.close(); + } + } catch (e) { + console.error('Error parsing test WS message:', e); + } + }; + + ws.onerror = () => { + if (!gotResult) { + hideOverlaySpinner(); + showToast(t('streams.test.error.failed'), 'error'); + } + }; + + ws.onclose = () => { + if (!gotResult) { + hideOverlaySpinner(); + } + }; } export async function saveTemplate() { @@ -1728,32 +1808,16 @@ function restoreStreamTestDuration() { document.getElementById('test-stream-duration-value').textContent = saved; } -export async function runStreamTest() { +export function runStreamTest() { if (!_currentTestStreamId) return; const captureDuration = parseFloat(document.getElementById('test-stream-duration').value); - showOverlaySpinner(t('streams.test.running'), captureDuration); - const signal = window._overlayAbortController?.signal; - try { - const response = await fetchWithAuth(`/picture-sources/${_currentTestStreamId}/test`, { - method: 'POST', - body: JSON.stringify({ 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(); - hideOverlaySpinner(); - const fullImageSrc = result.full_capture.full_image || result.full_capture.image; - openLightbox(fullImageSrc, buildTestStatsHtml(result)); - } catch (error) { - if (error.name === 'AbortError') return; - console.error('Error running stream test:', error); - hideOverlaySpinner(); - showToast(t('streams.test.error.failed') + ': ' + error.message, 'error'); - } + _runTestViaWS( + `/picture-sources/${_currentTestStreamId}/test/ws`, + { duration: captureDuration }, + null, + captureDuration, + ); } // ===== PP Template Test ===== @@ -1800,36 +1864,20 @@ function restorePPTestDuration() { document.getElementById('test-pp-duration-value').textContent = saved; } -export async function runPPTemplateTest() { +export function runPPTemplateTest() { if (!_currentTestPPTemplateId) return; const sourceStreamId = document.getElementById('test-pp-source-stream').value; if (!sourceStreamId) { showToast(t('postprocessing.test.error.no_stream'), 'error'); return; } localStorage.setItem('lastPPTestStreamId', sourceStreamId); const captureDuration = parseFloat(document.getElementById('test-pp-duration').value); - showOverlaySpinner(t('postprocessing.test.running'), captureDuration); - const signal = window._overlayAbortController?.signal; - try { - const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, { - method: 'POST', - body: JSON.stringify({ source_stream_id: sourceStreamId, 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(); - hideOverlaySpinner(); - const fullImageSrc = result.full_capture.full_image || result.full_capture.image; - openLightbox(fullImageSrc, buildTestStatsHtml(result)); - } catch (error) { - if (error.name === 'AbortError') return; - console.error('Error running PP template test:', error); - hideOverlaySpinner(); - showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error'); - } + _runTestViaWS( + `/postprocessing-templates/${_currentTestPPTemplateId}/test/ws`, + { duration: captureDuration, source_stream_id: sourceStreamId }, + null, + captureDuration, + ); } // ===== PP Templates ===== diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index e350730..5963254 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -903,6 +903,9 @@ "value_source.test.current": "Current", "value_source.test.min": "Min", "value_source.test.max": "Max", + "test.frames": "Frames", + "test.fps": "FPS", + "test.avg_capture": "Avg", "targets.brightness_vs": "Brightness Source:", "targets.brightness_vs.hint": "Optional value source that dynamically controls brightness each frame (overrides device brightness)", "targets.brightness_vs.none": "None (device brightness)", diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 6779378..a98f194 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -903,6 +903,9 @@ "value_source.test.current": "Текущее", "value_source.test.min": "Мин", "value_source.test.max": "Макс", + "test.frames": "Кадры", + "test.fps": "Кадр/с", + "test.avg_capture": "Сред", "targets.brightness_vs": "Источник яркости:", "targets.brightness_vs.hint": "Необязательный источник значений для динамического управления яркостью каждый кадр (переопределяет яркость устройства)", "targets.brightness_vs.none": "Нет (яркость устройства)", diff --git a/server/src/wled_controller/static/locales/zh.json b/server/src/wled_controller/static/locales/zh.json index 19f0a83..789d5f4 100644 --- a/server/src/wled_controller/static/locales/zh.json +++ b/server/src/wled_controller/static/locales/zh.json @@ -903,6 +903,9 @@ "value_source.test.current": "当前", "value_source.test.min": "最小", "value_source.test.max": "最大", + "test.frames": "帧数", + "test.fps": "帧率", + "test.avg_capture": "平均", "targets.brightness_vs": "亮度源:", "targets.brightness_vs.hint": "可选的值源,每帧动态控制亮度(覆盖设备亮度)", "targets.brightness_vs.none": "无(设备亮度)",