diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index d8d140d..8c60d05 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -53,6 +53,7 @@ from wled_controller.api.schemas import ( PictureStreamResponse, PictureStreamListResponse, PictureStreamTestRequest, + PPTemplateTestRequest, ) from wled_controller.config import get_config from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings @@ -1255,6 +1256,172 @@ async def delete_pp_template( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/api/v1/postprocessing-templates/{template_id}/test", response_model=TemplateTestResponse, tags=["Postprocessing Templates"]) +async def test_pp_template( + template_id: str, + test_request: PPTemplateTestRequest, + _auth: AuthRequired, + pp_store: PostprocessingTemplateStore = Depends(get_pp_template_store), + stream_store: PictureStreamStore = Depends(get_picture_stream_store), + template_store: TemplateStore = Depends(get_template_store), + processor_manager: ProcessorManager = Depends(get_processor_manager), + device_store: DeviceStore = Depends(get_device_store), +): + """Test a postprocessing template by capturing from a source stream and applying filters.""" + engine = None + try: + # Get the PP template + try: + pp_template = pp_store.get_template(template_id) + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + # Resolve source stream chain to get the raw stream + try: + chain = stream_store.resolve_stream_chain(test_request.source_stream_id) + except ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + + raw_stream = chain["raw_stream"] + + # Get capture template from raw stream + 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 + + # Validate engine + 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", + ) + + # Check display lock + locked_device_id = processor_manager.get_display_lock_info(display_index) + if locked_device_id: + try: + device = device_store.get_device(locked_device_id) + device_name = device.name + except Exception: + device_name = locked_device_id + raise HTTPException( + status_code=409, + detail=f"Display {display_index} is currently being captured by device '{device_name}'. " + f"Please stop the device processing before testing.", + ) + + # Create engine and run test + engine = EngineRegistry.create_engine(capture_template.engine_type, capture_template.engine_config) + + logger.info(f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_stream_id}") + + frame_count = 0 + total_capture_time = 0.0 + last_frame = None + + start_time = time.perf_counter() + end_time = start_time + test_request.capture_duration + + while time.perf_counter() < end_time: + capture_start = time.perf_counter() + screen_capture = engine.capture_display(display_index) + capture_elapsed = time.perf_counter() - capture_start + + 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") + + # Convert to PIL Image + if isinstance(last_frame.image, np.ndarray): + pil_image = Image.fromarray(last_frame.image) + else: + raise ValueError("Unexpected image format from engine") + + # Create thumbnail + thumbnail_width = 640 + aspect_ratio = pil_image.height / pil_image.width + thumbnail_height = int(thumbnail_width * aspect_ratio) + thumbnail = pil_image.copy() + thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS) + + # Apply postprocessing filters + if pp_template.filters: + pool = ImagePool() + + def apply_filters(img): + arr = np.array(img) + for fi in pp_template.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) + + thumbnail = apply_filters(thumbnail) + pil_image = apply_filters(pil_image) + + # Encode thumbnail + img_buffer = io.BytesIO() + thumbnail.save(img_buffer, format='JPEG', quality=85) + img_buffer.seek(0) + thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') + thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}" + + # Encode full-resolution image + full_buffer = io.BytesIO() + pil_image.save(full_buffer, format='JPEG', quality=90) + full_buffer.seek(0) + full_b64 = base64.b64encode(full_buffer.getvalue()).decode('utf-8') + 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 ValueError as e: + raise HTTPException(status_code=400, detail=str(e)) + except Exception as e: + logger.error(f"Postprocessing template test failed: {e}") + raise HTTPException(status_code=500, detail=str(e)) + finally: + if engine: + try: + engine.release() + except Exception: + pass + + # ===== PICTURE STREAM ENDPOINTS ===== def _stream_to_response(s) -> PictureStreamResponse: diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py index 26e1ba8..244110a 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -454,3 +454,10 @@ class PictureStreamTestRequest(BaseModel): capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds") border_width: int = Field(default=10, ge=1, le=100, description="Border width in pixels for preview") + + +class PPTemplateTestRequest(BaseModel): + """Request to test a postprocessing template against a source stream.""" + + source_stream_id: str = Field(description="ID of the source picture stream to capture from") + capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds") diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 94922ed..0624769 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -57,10 +57,33 @@ function closeLightbox(event) { document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { + // Close in order: overlay lightboxes first, then modals if (document.getElementById('display-picker-lightbox').classList.contains('active')) { closeDisplayPicker(); } else if (document.getElementById('image-lightbox').classList.contains('active')) { closeLightbox(); + } else { + // Close topmost visible modal + const modals = [ + { id: 'test-pp-template-modal', close: closeTestPPTemplateModal }, + { id: 'test-stream-modal', close: closeTestStreamModal }, + { id: 'test-template-modal', close: closeTestTemplateModal }, + { id: 'stream-modal', close: closeStreamModal }, + { id: 'pp-template-modal', close: closePPTemplateModal }, + { id: 'template-modal', close: closeTemplateModal }, + { id: 'device-settings-modal', close: forceCloseDeviceSettingsModal }, + { id: 'capture-settings-modal', close: forceCloseCaptureSettingsModal }, + { id: 'calibration-modal', close: forceCloseCalibrationModal }, + { id: 'stream-selector-modal', close: forceCloseStreamSelectorModal }, + { id: 'add-device-modal', close: closeAddDeviceModal }, + ]; + for (const m of modals) { + const el = document.getElementById(m.id); + if (el && el.style.display === 'flex') { + m.close(); + break; + } + } } } }); @@ -648,13 +671,12 @@ function createDeviceCard(device) {
${device.name || device.id} - ${device.url ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}` : ''} + ${device.url ? `${escapeHtml(device.url.replace(/^https?:\/\//, ''))}🌐` : ''} ${healthLabel} ${isProcessing ? `${t('device.status.processing')}` : ''}
- ${wledVersion ? `v${wledVersion}` : ''} ${ledCount ? `💡 ${ledCount}` : ''} ${state.wled_led_type ? `🔌 ${state.wled_led_type.replace(/ RGBW$/, '')}` : ''} ${state.wled_rgbw ? '' : ''} @@ -707,11 +729,6 @@ function createDeviceCard(device) { - ${device.url ? ` - - 🌐 - - ` : ''}
@@ -3033,6 +3050,17 @@ let _availableFilters = []; // Loaded from GET /filters async function loadPictureStreams() { try { + // Ensure PP templates are cached so processed stream cards can show filter info + if (_cachedPPTemplates.length === 0) { + try { + if (_availableFilters.length === 0) { + const fr = await fetchWithAuth('/filters'); + if (fr.ok) { const fd = await fr.json(); _availableFilters = fd.filters || []; } + } + const pr = await fetchWithAuth('/postprocessing-templates'); + if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; } + } catch (e) { console.warn('Could not pre-load PP templates for streams:', e); } + } const response = await fetchWithAuth('/picture-streams'); if (!response.ok) { throw new Error(`Failed to load streams: ${response.status}`); @@ -3102,10 +3130,19 @@ function renderPictureStreamsList(streams) { // Find source stream name and PP template name const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-'); + // Find PP template name + let ppTemplateHtml = ''; + if (stream.postprocessing_template_id) { + const ppTmpl = _cachedPPTemplates.find(p => p.id === stream.postprocessing_template_id); + if (ppTmpl) { + ppTemplateHtml = `
${t('streams.pp_template')} ${escapeHtml(ppTmpl.name)}
`; + } + } detailsHtml = `
${t('streams.source')} ${sourceName}
+ ${ppTemplateHtml} `; } @@ -3456,6 +3493,95 @@ function displayStreamTestResults(result) { openLightbox(fullImageSrc, buildTestStatsHtml(result)); } +// ===== PP Template Test ===== + +let _currentTestPPTemplateId = null; + +async function showTestPPTemplateModal(templateId) { + _currentTestPPTemplateId = templateId; + restorePPTestDuration(); + + // Populate source stream selector + const select = document.getElementById('test-pp-source-stream'); + select.innerHTML = ''; + // Ensure streams are cached + if (_cachedStreams.length === 0) { + try { + const resp = await fetchWithAuth('/picture-streams'); + if (resp.ok) { const d = await resp.json(); _cachedStreams = d.streams || []; } + } catch (e) { console.warn('Could not load streams for PP test:', e); } + } + for (const s of _cachedStreams) { + const opt = document.createElement('option'); + opt.value = s.id; + opt.textContent = s.name; + select.appendChild(opt); + } + // Auto-select last used stream + const lastStream = localStorage.getItem('lastPPTestStreamId'); + if (lastStream && _cachedStreams.find(s => s.id === lastStream)) { + select.value = lastStream; + } + + const modal = document.getElementById('test-pp-template-modal'); + modal.style.display = 'flex'; + lockBody(); + modal.onclick = (e) => { if (e.target === modal) closeTestPPTemplateModal(); }; +} + +function closeTestPPTemplateModal() { + document.getElementById('test-pp-template-modal').style.display = 'none'; + unlockBody(); + _currentTestPPTemplateId = null; +} + +function updatePPTestDuration(value) { + document.getElementById('test-pp-duration-value').textContent = value; + localStorage.setItem('lastPPTestDuration', value); +} + +function restorePPTestDuration() { + const saved = localStorage.getItem('lastPPTestDuration') || '5'; + document.getElementById('test-pp-duration').value = saved; + document.getElementById('test-pp-duration-value').textContent = saved; +} + +async 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); + + try { + const response = await fetchWithAuth(`/postprocessing-templates/${_currentTestPPTemplateId}/test`, { + method: 'POST', + body: JSON.stringify({ source_stream_id: sourceStreamId, capture_duration: captureDuration }) + }); + + 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) { + console.error('Error running PP template test:', error); + hideOverlaySpinner(); + showToast(t('postprocessing.test.error.failed') + ': ' + error.message, 'error'); + } +} + // ===== Processing Templates ===== async function loadAvailableFilters() { @@ -3514,12 +3640,12 @@ function renderPPTemplatesList(templates) { } const renderCard = (tmpl) => { - // Build config entries from filter list - const filterRows = (tmpl.filters || []).map(fi => { - const filterName = _getFilterName(fi.filter_id); - const optStr = Object.entries(fi.options || {}).map(([k, v]) => `${v}`).join(', '); - return `${escapeHtml(filterName)}${escapeHtml(optStr)}`; - }).join(''); + // Build filter chain pills + let filterChainHtml = ''; + if (tmpl.filters && tmpl.filters.length > 0) { + const filterNames = tmpl.filters.map(fi => `${escapeHtml(_getFilterName(fi.filter_id))}`); + filterChainHtml = `
${filterNames.join('')}
`; + } return `
@@ -3530,13 +3656,11 @@ function renderPPTemplatesList(templates) {
${tmpl.description ? `
${escapeHtml(tmpl.description)}
` : ''} -
- ${t('postprocessing.config.show')} - - ${filterRows} -
-
+ ${filterChainHtml}
+ diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 227e4ae..68e7783 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -490,6 +490,34 @@
+ + +