Files
wled-screen-controller-mixed/server/src/wled_controller/api/routes/picture_sources.py
alexei.dolgolyov cdba98813b Backend performance and code quality improvements
Performance (hot path):
- Fix double brightness: removed duplicate scaling from 9 device clients
  (wled, adalight, ambiled, openrgb, hue, spi, chroma, gamesense, usbhid,
  espnow) — processor loop is now the single source of brightness
- Bounded send_timestamps deque with maxlen, removed 3 cleanup loops
- Running FPS sum O(1) instead of sum()/len() O(n) per frame
- datetime.now(timezone.utc) → time.monotonic() with lazy conversion
- Device info refresh interval 30 → 300 iterations
- Composite: gate layer_snapshots copy on preview client flag
- Composite: versioned sub_streams snapshot (copy only on change)
- Composite: pre-resolved blend methods (dict lookup vs getattr)
- ApiInput: np.copyto in-place instead of astype allocation

Code quality:
- BaseJsonStore: RLock on get/delete/get_all/count (was created but unused)
- EntityNotFoundError → proper 404 responses across 15 route files
- Remove 21 defensive getattr(x,'tags',[]) — field guaranteed on all models
- Fix Dict[str,any] → Dict[str,Any] in template/audio_template stores
- Log 4 silenced exceptions (automation engine, metrics, system)
- ValueStream.get_value() now @abstractmethod
- Config.from_yaml: add encoding="utf-8"
- OutputTargetStore: remove 25-line _load override, use _legacy_json_keys
- BaseJsonStore: add _legacy_json_keys for migration support
- Remove unnecessary except Exception→500 from postprocessing list endpoint

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 15:06:29 +03:00

750 lines
29 KiB
Python

"""Picture source routes."""
import asyncio
import base64
import io
import time
import httpx
import numpy as np
from PIL import Image
from fastapi import APIRouter, HTTPException, Depends, Query, WebSocket, WebSocketDisconnect
from fastapi.responses import Response
from wled_controller.api.auth import AuthRequired
from wled_controller.api.dependencies import (
fire_entity_event,
get_picture_source_store,
get_output_target_store,
get_pp_template_store,
get_template_store,
)
from wled_controller.api.schemas.common import (
CaptureImage,
PerformanceMetrics,
TemplateTestResponse,
)
from wled_controller.api.schemas.picture_sources import (
ImageValidateRequest,
ImageValidateResponse,
PictureSourceCreate,
PictureSourceListResponse,
PictureSourceResponse,
PictureSourceTestRequest,
PictureSourceUpdate,
)
from wled_controller.core.capture_engines import EngineRegistry
from wled_controller.core.filters import FilterRegistry, ImagePool
from wled_controller.storage.output_target_store import OutputTargetStore
from wled_controller.storage.template_store import TemplateStore
from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore
from wled_controller.storage.picture_source_store import PictureSourceStore
from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource
from wled_controller.utils import get_logger
from wled_controller.storage.base_store import EntityNotFoundError
logger = get_logger(__name__)
router = APIRouter()
def _stream_to_response(s) -> PictureSourceResponse:
"""Convert a PictureSource to its API response."""
return PictureSourceResponse(
id=s.id,
name=s.name,
stream_type=s.stream_type,
display_index=getattr(s, "display_index", None),
capture_template_id=getattr(s, "capture_template_id", None),
target_fps=getattr(s, "target_fps", None),
source_stream_id=getattr(s, "source_stream_id", None),
postprocessing_template_id=getattr(s, "postprocessing_template_id", None),
image_source=getattr(s, "image_source", None),
created_at=s.created_at,
updated_at=s.updated_at,
description=s.description,
tags=s.tags,
# Video fields
url=getattr(s, "url", None),
loop=getattr(s, "loop", None),
playback_speed=getattr(s, "playback_speed", None),
start_time=getattr(s, "start_time", None),
end_time=getattr(s, "end_time", None),
resolution_limit=getattr(s, "resolution_limit", None),
clock_id=getattr(s, "clock_id", None),
)
@router.get("/api/v1/picture-sources", response_model=PictureSourceListResponse, tags=["Picture Sources"])
async def list_picture_sources(
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
):
"""List all picture sources."""
try:
streams = store.get_all_streams()
responses = [_stream_to_response(s) for s in streams]
return PictureSourceListResponse(streams=responses, count=len(responses))
except Exception as e:
logger.error(f"Failed to list picture sources: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/picture-sources/validate-image", response_model=ImageValidateResponse, tags=["Picture Sources"])
async def validate_image(
data: ImageValidateRequest,
_auth: AuthRequired,
):
"""Validate an image source (URL or file path) and return a preview thumbnail."""
try:
from pathlib import Path
source = data.image_source.strip()
if not source:
return ImageValidateResponse(valid=False, error="Image source is empty")
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(source)
response.raise_for_status()
img_bytes = response.content
else:
path = Path(source)
if not path.exists():
return ImageValidateResponse(valid=False, error=f"File not found: {source}")
img_bytes = path
def _process_image(src):
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
pil_image = pil_image.convert("RGB")
width, height = pil_image.size
thumb = pil_image.copy()
thumb.thumbnail((320, 320), Image.Resampling.LANCZOS)
buf = io.BytesIO()
thumb.save(buf, format="JPEG", quality=80)
buf.seek(0)
preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}"
return width, height, preview
width, height, preview = await asyncio.to_thread(_process_image, img_bytes)
return ImageValidateResponse(
valid=True, width=width, height=height, preview=preview
)
except httpx.HTTPStatusError as e:
return ImageValidateResponse(valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}")
except httpx.RequestError as e:
return ImageValidateResponse(valid=False, error=f"Request failed: {e}")
except Exception as e:
return ImageValidateResponse(valid=False, error=str(e))
@router.get("/api/v1/picture-sources/full-image", tags=["Picture Sources"])
async def get_full_image(
_auth: AuthRequired,
source: str = Query(..., description="Image URL or local file path"),
):
"""Serve the full-resolution image for lightbox preview."""
from pathlib import Path
try:
if source.startswith(("http://", "https://")):
async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client:
response = await client.get(source)
response.raise_for_status()
img_bytes = response.content
else:
path = Path(source)
if not path.exists():
raise HTTPException(status_code=404, detail="File not found")
img_bytes = path
def _encode_full(src):
pil_image = Image.open(io.BytesIO(src) if isinstance(src, bytes) else src)
pil_image = pil_image.convert("RGB")
buf = io.BytesIO()
pil_image.save(buf, format="JPEG", quality=90)
return buf.getvalue()
jpeg_bytes = await asyncio.to_thread(_encode_full, img_bytes)
return Response(content=jpeg_bytes, media_type="image/jpeg")
except HTTPException:
raise
except Exception as e:
raise HTTPException(status_code=400, detail=str(e))
@router.post("/api/v1/picture-sources", response_model=PictureSourceResponse, tags=["Picture Sources"], status_code=201)
async def create_picture_source(
data: PictureSourceCreate,
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store),
):
"""Create a new picture source."""
try:
# Validate referenced entities
if data.stream_type == "raw" and data.capture_template_id:
try:
template_store.get_template(data.capture_template_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Capture template not found: {data.capture_template_id}",
)
if data.stream_type == "processed" and data.postprocessing_template_id:
try:
pp_store.get_template(data.postprocessing_template_id)
except ValueError:
raise HTTPException(
status_code=400,
detail=f"Postprocessing template not found: {data.postprocessing_template_id}",
)
stream = store.create_stream(
name=data.name,
stream_type=data.stream_type,
display_index=data.display_index,
capture_template_id=data.capture_template_id,
target_fps=data.target_fps,
source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id,
image_source=data.image_source,
description=data.description,
tags=data.tags,
# Video fields
url=data.url,
loop=data.loop,
playback_speed=data.playback_speed,
start_time=data.start_time,
end_time=data.end_time,
resolution_limit=data.resolution_limit,
clock_id=data.clock_id,
)
fire_entity_event("picture_source", "created", stream.id)
return _stream_to_response(stream)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to create picture source: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
async def get_picture_source(
stream_id: str,
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Get picture source by ID."""
try:
stream = store.get_stream(stream_id)
return _stream_to_response(stream)
except ValueError:
raise HTTPException(status_code=404, detail=f"Picture source {stream_id} not found")
@router.put("/api/v1/picture-sources/{stream_id}", response_model=PictureSourceResponse, tags=["Picture Sources"])
async def update_picture_source(
stream_id: str,
data: PictureSourceUpdate,
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Update a picture source."""
try:
stream = store.update_stream(
stream_id=stream_id,
name=data.name,
display_index=data.display_index,
capture_template_id=data.capture_template_id,
target_fps=data.target_fps,
source_stream_id=data.source_stream_id,
postprocessing_template_id=data.postprocessing_template_id,
image_source=data.image_source,
description=data.description,
tags=data.tags,
# Video fields
url=data.url,
loop=data.loop,
playback_speed=data.playback_speed,
start_time=data.start_time,
end_time=data.end_time,
resolution_limit=data.resolution_limit,
clock_id=data.clock_id,
)
fire_entity_event("picture_source", "updated", stream_id)
return _stream_to_response(stream)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to update picture source: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.delete("/api/v1/picture-sources/{stream_id}", status_code=204, tags=["Picture Sources"])
async def delete_picture_source(
stream_id: str,
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
target_store: OutputTargetStore = Depends(get_output_target_store),
):
"""Delete a picture source."""
try:
# Check if any target references this stream
target_names = store.get_targets_referencing(stream_id, target_store)
if target_names:
names = ", ".join(target_names)
raise HTTPException(
status_code=409,
detail=f"Cannot delete picture source: it is assigned to target(s): {names}. "
"Please reassign those targets before deleting.",
)
store.delete_stream(stream_id)
fire_entity_event("picture_source", "deleted", stream_id)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except Exception as e:
logger.error(f"Failed to delete picture source: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"])
async def get_video_thumbnail(
stream_id: str,
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
):
"""Get a thumbnail for a video picture source (first frame)."""
import base64
from io import BytesIO
from PIL import Image
from wled_controller.core.processing.video_stream import extract_thumbnail
from wled_controller.storage.picture_source import VideoCaptureSource
try:
source = store.get_stream(stream_id)
if not isinstance(source, VideoCaptureSource):
raise HTTPException(status_code=400, detail="Not a video source")
frame = await asyncio.get_event_loop().run_in_executor(
None, extract_thumbnail, source.url, source.resolution_limit
)
if frame is None:
raise HTTPException(status_code=404, detail="Could not extract thumbnail")
# Encode as JPEG
pil_img = Image.fromarray(frame)
# Resize to max 320px wide for thumbnail
if pil_img.width > 320:
ratio = 320 / pil_img.width
pil_img = pil_img.resize((320, int(pil_img.height * ratio)), Image.LANCZOS)
buf = BytesIO()
pil_img.save(buf, format="JPEG", quality=80)
b64 = base64.b64encode(buf.getvalue()).decode()
return {"thumbnail": f"data:image/jpeg;base64,{b64}", "width": pil_img.width, "height": pil_img.height}
except HTTPException:
raise
except Exception as e:
logger.error(f"Failed to extract video thumbnail: {e}")
raise HTTPException(status_code=500, detail=str(e))
@router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"])
async def test_picture_source(
stream_id: str,
test_request: PictureSourceTestRequest,
_auth: AuthRequired,
store: PictureSourceStore = Depends(get_picture_source_store),
template_store: TemplateStore = Depends(get_template_store),
pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store),
):
"""Test a picture source by resolving its chain and running a capture test.
Resolves the stream chain to the raw stream, captures frames,
and returns preview image + performance metrics.
For processed streams, applies postprocessing (gamma, saturation, brightness)
to the preview image.
"""
stream = None
try:
# Resolve stream chain
try:
chain = store.resolve_stream_chain(stream_id)
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
raw_stream = chain["raw_stream"]
if isinstance(raw_stream, StaticImagePictureSource):
# Static image stream: load image directly, no engine needed
from pathlib import Path
source = raw_stream.image_source
start_time = time.perf_counter()
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:
path = Path(source)
if not path.exists():
raise HTTPException(status_code=400, detail=f"Image file not found: {source}")
pil_image = await asyncio.to_thread(lambda: Image.open(path).convert("RGB"))
actual_duration = time.perf_counter() - start_time
frame_count = 1
total_capture_time = actual_duration
elif isinstance(raw_stream, ScreenCapturePictureSource):
# Screen capture stream: use engine
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",
)
stream = EngineRegistry.create_stream(
capture_template.engine_type, display_index, capture_template.engine_config
)
stream.initialize()
frame_count = 0
total_capture_time = 0.0
last_frame = None
start_time = time.perf_counter()
if test_request.capture_duration == 0:
# Single frame capture
logger.info(f"Capturing single frame for {stream_id}")
capture_start = time.perf_counter()
screen_capture = stream.capture_frame()
capture_elapsed = time.perf_counter() - capture_start
if screen_capture is not None:
total_capture_time = capture_elapsed
frame_count = 1
last_frame = screen_capture
else:
logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}")
end_time = start_time + test_request.capture_duration
while time.perf_counter() < end_time:
capture_start = time.perf_counter()
screen_capture = stream.capture_frame()
capture_elapsed = time.perf_counter() - capture_start
if screen_capture is None:
continue
total_capture_time += capture_elapsed
frame_count += 1
last_frame = screen_capture
actual_duration = time.perf_counter() - start_time
if last_frame is None:
raise RuntimeError("No frames captured during test")
if isinstance(last_frame.image, np.ndarray):
pil_image = Image.fromarray(last_frame.image)
else:
raise ValueError("Unexpected image format from engine")
# Create thumbnail + encode (CPU-bound — run in thread)
pp_template_ids = chain["postprocessing_template_ids"]
flat_filters = None
if pp_template_ids:
try:
pp_template = pp_store.get_template(pp_template_ids[0])
flat_filters = pp_store.resolve_filter_instances(pp_template.filters) or None
except ValueError:
logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview")
def _create_thumbnails_and_encode(pil_img, filters):
thumbnail_w = 640
aspect_ratio = pil_img.height / pil_img.width
thumbnail_h = int(thumbnail_w * aspect_ratio)
thumb = pil_img.copy()
thumb.thumbnail((thumbnail_w, thumbnail_h), Image.Resampling.LANCZOS)
if filters:
pool = ImagePool()
def apply_filters(img):
arr = np.array(img)
for fi in 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)
thumb = apply_filters(thumb)
pil_img = apply_filters(pil_img)
img_buffer = io.BytesIO()
thumb.save(img_buffer, format='JPEG', quality=85)
thumb_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8')
full_buffer = io.BytesIO()
pil_img.save(full_buffer, format='JPEG', quality=90)
full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8')
return thumbnail_w, thumbnail_h, thumb_b64, full_b64
thumbnail_width, thumbnail_height, thumbnail_b64, full_b64 = await asyncio.to_thread(
_create_thumbnails_and_encode, pil_image, flat_filters
)
thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}"
full_data_uri = f"data:image/jpeg;base64,{full_b64}"
actual_fps = frame_count / actual_duration if actual_duration > 0 else 0
avg_capture_time_ms = (total_capture_time / frame_count * 1000) if frame_count > 0 else 0
width, height = pil_image.size
return TemplateTestResponse(
full_capture=CaptureImage(
image=thumbnail_data_uri,
full_image=full_data_uri,
width=width,
height=height,
thumbnail_width=thumbnail_width,
thumbnail_height=thumbnail_height,
),
border_extraction=None,
performance=PerformanceMetrics(
capture_duration_s=actual_duration,
frame_count=frame_count,
actual_fps=actual_fps,
avg_capture_time_ms=avg_capture_time_ms,
),
)
except HTTPException:
raise
except EntityNotFoundError as e:
raise HTTPException(status_code=404, detail=str(e))
except ValueError as e:
raise HTTPException(status_code=400, detail=str(e))
except RuntimeError as e:
raise HTTPException(status_code=500, detail=f"Engine error: {str(e)}")
except Exception as e:
logger.error(f"Failed to test picture source: {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}")
# ===== 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
# Video sources: use VideoCaptureLiveStream for test preview
if isinstance(raw_stream, VideoCaptureSource):
from wled_controller.core.processing.video_stream import VideoCaptureLiveStream
await websocket.accept()
logger.info(f"Video source test WS connected for {stream_id} ({duration}s)")
video_stream = VideoCaptureLiveStream(
url=raw_stream.url,
loop=raw_stream.loop,
playback_speed=raw_stream.playback_speed,
start_time=raw_stream.start_time,
end_time=raw_stream.end_time,
resolution_limit=raw_stream.resolution_limit,
target_fps=raw_stream.target_fps,
)
def _encode_video_frame(image, pw):
"""Encode numpy RGB image as JPEG base64 data URI."""
from PIL import Image as PILImage
pil = PILImage.fromarray(image)
if pw and pil.width > pw:
ratio = pw / pil.width
pil = pil.resize((pw, int(pil.height * ratio)), PILImage.LANCZOS)
buf = io.BytesIO()
pil.save(buf, format="JPEG", quality=80)
b64 = base64.b64encode(buf.getvalue()).decode()
return f"data:image/jpeg;base64,{b64}", pil.width, pil.height
try:
await asyncio.get_event_loop().run_in_executor(None, video_stream.start)
import time as _time
fps = min(raw_stream.target_fps or 30, 30)
frame_time = 1.0 / fps
end_at = _time.monotonic() + duration
frame_count = 0
last_frame = None
while _time.monotonic() < end_at:
frame = video_stream.get_latest_frame()
if frame is not None and frame.image is not None and frame is not last_frame:
last_frame = frame
frame_count += 1
thumb, w, h = await asyncio.get_event_loop().run_in_executor(
None, _encode_video_frame, frame.image, preview_width or None,
)
elapsed = duration - (end_at - _time.monotonic())
await websocket.send_json({
"type": "frame",
"thumbnail": thumb,
"width": w, "height": h,
"elapsed": round(elapsed, 1),
"frame_count": frame_count,
})
await asyncio.sleep(frame_time)
# Send final result
if last_frame is not None:
full_img, fw, fh = await asyncio.get_event_loop().run_in_executor(
None, _encode_video_frame, last_frame.image, None,
)
await websocket.send_json({
"type": "result",
"full_image": full_img,
"width": fw, "height": fh,
"total_frames": frame_count,
"duration": duration,
"avg_fps": round(frame_count / max(duration, 0.001), 1),
})
except WebSocketDisconnect:
pass
except Exception as e:
logger.error(f"Video source test WS error for {stream_id}: {e}")
try:
await websocket.send_json({"type": "error", "detail": str(e)})
except Exception:
pass
finally:
video_stream.stop()
logger.info(f"Video source test WS disconnected for {stream_id}")
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}")