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 <noreply@anthropic.com>
This commit is contained in:
2026-02-26 16:34:30 +03:00
parent 3c35bf0c49
commit b8bfdac36b
10 changed files with 669 additions and 90 deletions

View File

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