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:
@@ -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;
|
||||
|
||||
@@ -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 = {};
|
||||
|
||||
@@ -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 = `
|
||||
<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) {
|
||||
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 =====
|
||||
|
||||
@@ -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)",
|
||||
|
||||
@@ -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": "Нет (яркость устройства)",
|
||||
|
||||
@@ -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": "无(设备亮度)",
|
||||
|
||||
Reference in New Issue
Block a user