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) {
- ${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')}
-
-
+ ${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 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json
index e7031b2..fce5cc6 100644
--- a/server/src/wled_controller/static/locales/en.json
+++ b/server/src/wled_controller/static/locales/en.json
@@ -260,6 +260,11 @@
"postprocessing.error.required": "Please fill in all required fields",
"postprocessing.error.delete": "Failed to delete processing template",
"postprocessing.config.show": "Show settings",
+ "postprocessing.test.title": "Test Processing Template",
+ "postprocessing.test.source_stream": "Source Stream:",
+ "postprocessing.test.running": "Testing processing template...",
+ "postprocessing.test.error.no_stream": "Please select a source stream",
+ "postprocessing.test.error.failed": "Processing template test failed",
"device.button.stream_selector": "Stream Settings",
"device.stream_settings.title": "📺 Stream Settings",
"device.stream_selector.label": "Picture Stream:",
diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json
index b7e48f3..2a8593f 100644
--- a/server/src/wled_controller/static/locales/ru.json
+++ b/server/src/wled_controller/static/locales/ru.json
@@ -260,6 +260,11 @@
"postprocessing.error.required": "Пожалуйста, заполните все обязательные поля",
"postprocessing.error.delete": "Не удалось удалить шаблон обработки",
"postprocessing.config.show": "Показать настройки",
+ "postprocessing.test.title": "Тест шаблона обработки",
+ "postprocessing.test.source_stream": "Источник потока:",
+ "postprocessing.test.running": "Тестирование шаблона обработки...",
+ "postprocessing.test.error.no_stream": "Пожалуйста, выберите источник потока",
+ "postprocessing.test.error.failed": "Тест шаблона обработки не удался",
"device.button.stream_selector": "Настройки потока",
"device.stream_settings.title": "📺 Настройки потока",
"device.stream_selector.label": "Видеопоток:",
diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css
index ecb5dfd..2bf48c7 100644
--- a/server/src/wled_controller/static/style.css
+++ b/server/src/wled_controller/static/style.css
@@ -243,6 +243,7 @@ section {
color: var(--primary-color);
}
+
.card-remove-btn {
position: absolute;
top: 10px;
@@ -284,6 +285,9 @@ section {
}
.device-url-badge {
+ display: inline-flex;
+ align-items: center;
+ gap: 4px;
font-size: 0.7rem;
font-weight: 400;
color: var(--text-secondary);
@@ -292,6 +296,16 @@ section {
border-radius: 10px;
letter-spacing: 0.03em;
font-family: monospace;
+ text-decoration: none;
+ transition: background 0.2s;
+}
+
+.device-url-badge:hover {
+ background: var(--text-muted);
+}
+
+.device-url-icon {
+ font-size: 0.6rem;
}
.card-subtitle {
@@ -1791,6 +1805,27 @@ input:-webkit-autofill:focus {
margin-bottom: 8px;
}
+.filter-chain {
+ display: flex;
+ align-items: center;
+ flex-wrap: wrap;
+ gap: 4px;
+ margin-bottom: 8px;
+}
+
+.filter-chain-item {
+ font-size: 0.7rem;
+ background: var(--border-color);
+ color: var(--text-secondary);
+ padding: 2px 8px;
+ border-radius: 10px;
+}
+
+.filter-chain-arrow {
+ font-size: 0.7rem;
+ color: var(--text-muted);
+}
+
.template-config-details {
margin: 12px 0;
font-size: 13px;