From e8cbc731619b1224529a1c500d35184757ac5dd3 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 11 Feb 2026 00:35:06 +0300 Subject: [PATCH] Improve stream UI: grouped sections, full-size preview lightbox, and test redesign - Separate Screen Capture and Processed streams into grouped sections with headers - Remove redundant Type dropdown from stream modal (type inferred from add button) - Add full-resolution image to test endpoints alongside thumbnails - Add image lightbox with clickable preview for full-size viewing - Move test results from modal into lightbox overlay with capture stats - Apply postprocessing to both thumbnail and full image for processed streams - Rename "Assigned Picture Stream" to "Picture Stream" in device settings - Fix null reference errors from removed test result HTML elements Co-Authored-By: Claude Opus 4.6 --- server/src/wled_controller/api/routes.py | 58 +++--- server/src/wled_controller/api/schemas.py | 7 +- server/src/wled_controller/static/app.js | 165 +++++++++++++----- server/src/wled_controller/static/index.html | 75 ++------ .../wled_controller/static/locales/en.json | 8 +- .../wled_controller/static/locales/ru.json | 8 +- server/src/wled_controller/static/style.css | 125 +++++++++++++ 7 files changed, 308 insertions(+), 138 deletions(-) diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index 7e06bd9..566898a 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -1052,12 +1052,19 @@ async def test_template( thumbnail = pil_image.copy() thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS) - # Encode full capture thumbnail as JPEG + # Encode thumbnail as JPEG img_buffer = io.BytesIO() thumbnail.save(img_buffer, format='JPEG', quality=85) img_buffer.seek(0) - full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') - full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}" + thumbnail_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') + thumbnail_data_uri = f"data:image/jpeg;base64,{thumbnail_b64}" + + # Encode full-resolution image as JPEG + 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}" # Calculate metrics actual_fps = frame_count / actual_duration if actual_duration > 0 else 0 @@ -1067,7 +1074,8 @@ async def test_template( return TemplateTestResponse( full_capture=CaptureImage( - image=full_capture_data_uri, + image=thumbnail_data_uri, + full_image=full_data_uri, width=width, height=height, thumbnail_width=thumbnail_width, @@ -1469,34 +1477,41 @@ async def test_picture_stream( thumbnail = pil_image.copy() thumbnail.thumbnail((thumbnail_width, thumbnail_height), Image.Resampling.LANCZOS) - # Apply postprocessing to preview if this is a processed stream + # Apply postprocessing if this is a processed stream pp_template_ids = chain["postprocessing_template_ids"] if pp_template_ids: try: pp = pp_store.get_template(pp_template_ids[0]) - img_array = np.array(thumbnail, dtype=np.float32) / 255.0 - if pp.brightness != 1.0: - img_array *= pp.brightness + def apply_pp(img): + arr = np.array(img, dtype=np.float32) / 255.0 + if pp.brightness != 1.0: + arr *= pp.brightness + if pp.saturation != 1.0: + lum = np.dot(arr[..., :3], [0.299, 0.587, 0.114])[..., np.newaxis] + arr[..., :3] = lum + (arr[..., :3] - lum) * pp.saturation + if pp.gamma != 1.0: + arr = np.power(np.clip(arr, 0, 1), 1.0 / pp.gamma) + return Image.fromarray(np.clip(arr * 255.0, 0, 255).astype(np.uint8)) - if pp.saturation != 1.0: - luminance = np.dot(img_array[..., :3], [0.299, 0.587, 0.114]) - luminance = luminance[..., np.newaxis] - img_array[..., :3] = luminance + (img_array[..., :3] - luminance) * pp.saturation - - if pp.gamma != 1.0: - img_array = np.power(np.clip(img_array, 0, 1), 1.0 / pp.gamma) - - img_array = np.clip(img_array * 255.0, 0, 255).astype(np.uint8) - thumbnail = Image.fromarray(img_array) + thumbnail = apply_pp(thumbnail) + pil_image = apply_pp(pil_image) except ValueError: logger.warning(f"PP template {pp_template_ids[0]} not found, skipping postprocessing preview") + # Encode thumbnail img_buffer = io.BytesIO() thumbnail.save(img_buffer, format='JPEG', quality=85) img_buffer.seek(0) - full_capture_b64 = base64.b64encode(img_buffer.getvalue()).decode('utf-8') - full_capture_data_uri = f"data:image/jpeg;base64,{full_capture_b64}" + 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 @@ -1504,7 +1519,8 @@ async def test_picture_stream( return TemplateTestResponse( full_capture=CaptureImage( - image=full_capture_data_uri, + image=thumbnail_data_uri, + full_image=full_data_uri, width=width, height=height, thumbnail_width=thumbnail_width, diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py index 9c1675a..407d887 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -292,9 +292,10 @@ class TemplateTestRequest(BaseModel): class CaptureImage(BaseModel): """Captured image with metadata.""" - image: str = Field(description="Base64-encoded image data") - width: int = Field(description="Image width in pixels") - height: int = Field(description="Image height in pixels") + image: str = Field(description="Base64-encoded thumbnail image data") + full_image: Optional[str] = Field(None, description="Base64-encoded full-resolution image data") + width: int = Field(description="Original image width in pixels") + height: int = Field(description="Original image height in pixels") thumbnail_width: Optional[int] = Field(None, description="Thumbnail width (if resized)") thumbnail_height: Optional[int] = Field(None, description="Thumbnail height (if resized)") diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 273c778..806fa74 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -30,6 +30,37 @@ function unlockBody() { document.body.style.paddingRight = ''; } +// Image lightbox +function openLightbox(imageSrc, statsHtml) { + const lightbox = document.getElementById('image-lightbox'); + const img = document.getElementById('lightbox-image'); + const statsEl = document.getElementById('lightbox-stats'); + img.src = imageSrc; + if (statsHtml) { + statsEl.innerHTML = statsHtml; + statsEl.style.display = ''; + } else { + statsEl.style.display = 'none'; + } + lightbox.classList.add('active'); + lockBody(); +} + +function closeLightbox(event) { + if (event && event.target && event.target.closest('.lightbox-content')) return; + const lightbox = document.getElementById('image-lightbox'); + lightbox.classList.remove('active'); + document.getElementById('lightbox-image').src = ''; + document.getElementById('lightbox-stats').style.display = 'none'; + unlockBody(); +} + +document.addEventListener('keydown', (e) => { + if (e.key === 'Escape' && document.getElementById('image-lightbox').classList.contains('active')) { + closeLightbox(); + } +}); + // Locale management let currentLocale = 'en'; let translations = {}; @@ -2638,9 +2669,6 @@ async function showTestTemplateModal(templateId) { // Restore last used capture duration restoreCaptureDuration(); - // Reset results - document.getElementById('test-template-results').style.display = 'none'; - // Show modal const modal = document.getElementById('test-template-modal'); modal.style.display = 'flex'; @@ -2836,7 +2864,6 @@ async function runTemplateTest() { } const template = window.currentTestingTemplate; - const resultsDiv = document.getElementById('test-template-results'); // Show full-page overlay spinner with progress showOverlaySpinner(t('templates.test.running'), captureDuration); @@ -2869,25 +2896,23 @@ async function runTemplateTest() { } } -// Display test results +function buildTestStatsHtml(result) { + const p = result.performance; + const res = `${result.full_capture.width}x${result.full_capture.height}`; + return ` +
${t('templates.test.results.duration')}: ${p.capture_duration_s.toFixed(2)}s
+
${t('templates.test.results.frame_count')}: ${p.frame_count}
+
${t('templates.test.results.actual_fps')}: ${p.actual_fps.toFixed(1)}
+
${t('templates.test.results.avg_capture_time')}: ${p.avg_capture_time_ms.toFixed(1)}ms
+
Resolution: ${res}
+ `; +} + +// Display test results — opens lightbox with stats overlay function displayTestResults(result) { - const resultsDiv = document.getElementById('test-template-results'); - - // Hide overlay spinner hideOverlaySpinner(); - - // Full capture preview - const previewImg = document.getElementById('test-template-preview-image'); - previewImg.innerHTML = `Capture preview`; - - // Performance stats - document.getElementById('test-template-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`; - document.getElementById('test-template-frame-count').textContent = result.performance.frame_count; - document.getElementById('test-template-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`; - document.getElementById('test-template-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`; - - // Show results - resultsDiv.style.display = 'block'; + const fullImageSrc = result.full_capture.full_image || result.full_capture.image; + openLightbox(fullImageSrc, buildTestStatsHtml(result)); } // Save template @@ -2989,9 +3014,32 @@ function renderPictureStreamsList(streams) { const container = document.getElementById('streams-list'); if (streams.length === 0) { - container.innerHTML = `
-
+
-
${t('streams.add')}
+ container.innerHTML = ` +
+
+ 📷 + ${t('streams.group.raw')} + 0 +
+
+
+
+
+
${t('streams.add.raw')}
+
+
+
+
+
+ 🎨 + ${t('streams.group.processed')} + 0 +
+
+
+
+
+
${t('streams.add.processed')}
+
+
`; return; } @@ -3046,10 +3094,41 @@ function renderPictureStreamsList(streams) { `; }; - let html = streams.map(renderCard).join(''); - html += `
-
+
-
${t('streams.add')}
+ const rawStreams = streams.filter(s => s.stream_type === 'raw'); + const processedStreams = streams.filter(s => s.stream_type === 'processed'); + + let html = ''; + + // Screen Capture streams section + html += `
+
+ 📷 + ${t('streams.group.raw')} + ${rawStreams.length} +
+
+ ${rawStreams.map(renderCard).join('')} +
+
+
+
${t('streams.add.raw')}
+
+
+
`; + + // Processed streams section + html += `
+
+ 🎨 + ${t('streams.group.processed')} + ${processedStreams.length} +
+
+ ${processedStreams.map(renderCard).join('')} +
+
+
+
${t('streams.add.processed')}
+
+
`; container.innerHTML = html; @@ -3061,15 +3140,14 @@ function onStreamTypeChange() { document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none'; } -async function showAddStreamModal() { - document.getElementById('stream-modal-title').textContent = t('streams.add'); +async function showAddStreamModal(presetType) { + const streamType = presetType || 'raw'; + const titleKey = streamType === 'raw' ? 'streams.add.raw' : 'streams.add.processed'; + document.getElementById('stream-modal-title').textContent = t(titleKey); document.getElementById('stream-form').reset(); document.getElementById('stream-id').value = ''; document.getElementById('stream-error').style.display = 'none'; - document.getElementById('stream-type').disabled = false; - - // Reset to raw type - document.getElementById('stream-type').value = 'raw'; + document.getElementById('stream-type').value = streamType; onStreamTypeChange(); // Populate dropdowns @@ -3087,15 +3165,15 @@ async function editStream(streamId) { if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`); const stream = await response.json(); - document.getElementById('stream-modal-title').textContent = t('streams.edit'); + const editTitleKey = stream.stream_type === 'raw' ? 'streams.edit.raw' : 'streams.edit.processed'; + document.getElementById('stream-modal-title').textContent = t(editTitleKey); document.getElementById('stream-id').value = streamId; document.getElementById('stream-name').value = stream.name; document.getElementById('stream-description').value = stream.description || ''; document.getElementById('stream-error').style.display = 'none'; - // Set type and disable changing it for existing streams + // Set type (hidden input) document.getElementById('stream-type').value = stream.stream_type; - document.getElementById('stream-type').disabled = true; onStreamTypeChange(); // Populate dropdowns before setting values @@ -3286,7 +3364,6 @@ let _currentTestStreamId = null; async function showTestStreamModal(streamId) { _currentTestStreamId = streamId; restoreStreamTestDuration(); - document.getElementById('test-stream-results').style.display = 'none'; const modal = document.getElementById('test-stream-modal'); modal.style.display = 'flex'; @@ -3340,16 +3417,8 @@ async function runStreamTest() { function displayStreamTestResults(result) { hideOverlaySpinner(); - - const previewImg = document.getElementById('test-stream-preview-image'); - previewImg.innerHTML = `Stream preview`; - - document.getElementById('test-stream-actual-duration').textContent = `${result.performance.capture_duration_s.toFixed(2)}s`; - document.getElementById('test-stream-frame-count').textContent = result.performance.frame_count; - document.getElementById('test-stream-actual-fps').textContent = `${result.performance.actual_fps.toFixed(1)} FPS`; - document.getElementById('test-stream-avg-capture-time').textContent = `${result.performance.avg_capture_time_ms.toFixed(1)}ms`; - - document.getElementById('test-stream-results').style.display = 'block'; + const fullImageSrc = result.full_capture.full_image || result.full_capture.image; + openLightbox(fullImageSrc, buildTestStatsHtml(result)); } // ===== Processing Templates ===== diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 2ad89f2..2a11607 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -70,7 +70,7 @@ Picture streams define the capture pipeline. A raw stream captures from a display using a capture template. A processed stream applies postprocessing to another stream. Assign streams to devices.

-
+
@@ -460,34 +460,6 @@ 🧪 Run Test -
@@ -512,34 +484,6 @@ 🧪 Run Test - @@ -559,13 +503,7 @@ -
- - -
+
@@ -692,6 +630,15 @@
+ + +