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:
215
server/src/wled_controller/api/routes/_test_helpers.py
Normal file
215
server/src/wled_controller/api/routes/_test_helpers.py
Normal file
@@ -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
|
||||
@@ -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}")
|
||||
|
||||
@@ -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}")
|
||||
|
||||
@@ -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"])
|
||||
|
||||
Reference in New Issue
Block a user