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

@@ -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

View File

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

View File

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

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

View File

@@ -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;

View File

@@ -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 = {};

View File

@@ -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({
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
}),
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');
}
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 = `
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${p.capture_duration_s.toFixed(2)}s</strong></div>
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${p.frame_count}</strong></div>`;
if (p.frame_count > 1) {
<div class="stat-item"><span>${t('templates.test.results.duration')}:</span> <strong>${Number(duration).toFixed(2)}s</strong></div>
<div class="stat-item"><span>${t('templates.test.results.frame_count')}:</span> <strong>${frameCount}</strong></div>`;
if (frameCount > 1) {
html += `
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${p.actual_fps.toFixed(1)}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${p.avg_capture_time_ms.toFixed(1)}ms</strong></div>`;
<div class="stat-item"><span>${t('templates.test.results.actual_fps')}:</span> <strong>${Number(fps).toFixed(1)}</strong></div>
<div class="stat-item"><span>${t('templates.test.results.avg_capture_time')}:</span> <strong>${Number(avgMs).toFixed(1)}ms</strong></div>`;
}
html += `
<div class="stat-item"><span>Resolution:</span> <strong>${res}</strong></div>`;
return html;
}
function displayTestResults(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();
const fullImageSrc = result.full_capture.full_image || result.full_capture.image;
openLightbox(fullImageSrc, buildTestStatsHtml(result));
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 =====

View File

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

View File

@@ -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": "Нет (яркость устройства)",

View File

@@ -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": "无(设备亮度)",