diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index f6caaa0..64de2ca 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -10,7 +10,8 @@ from typing import List, Dict, Any import httpx import numpy as np from PIL import Image -from fastapi import APIRouter, HTTPException, Depends +from fastapi import APIRouter, HTTPException, Depends, Query +from fastapi.responses import Response from wled_controller import __version__ from wled_controller.api.auth import AuthRequired @@ -1612,6 +1613,38 @@ async def validate_image( return ImageValidateResponse(valid=False, error=str(e)) +@router.get("/api/v1/picture-streams/full-image", tags=["Picture Streams"]) +async def get_full_image( + _auth: AuthRequired, + source: str = Query(..., description="Image URL or local file path"), +): + """Serve the full-resolution image for lightbox preview.""" + from pathlib import Path + + try: + if source.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + response = await client.get(source) + response.raise_for_status() + pil_image = Image.open(io.BytesIO(response.content)) + else: + path = Path(source) + if not path.exists(): + raise HTTPException(status_code=404, detail="File not found") + pil_image = Image.open(path) + + pil_image = pil_image.convert("RGB") + buf = io.BytesIO() + pil_image.save(buf, format="JPEG", quality=90) + buf.seek(0) + return Response(content=buf.getvalue(), media_type="image/jpeg") + + except HTTPException: + raise + except Exception as e: + raise HTTPException(status_code=400, detail=str(e)) + + @router.post("/api/v1/picture-streams", response_model=PictureStreamResponse, tags=["Picture Streams"], status_code=201) async def create_picture_stream( data: PictureStreamCreate, diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 16a96f3..bc6136b 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -120,6 +120,8 @@ class ProcessorState: # Capture libraries (BetterCam, MSS, DXcam) use thread-local state, # so all calls must run on the same thread. capture_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None + # WLED state snapshot taken before streaming starts (to restore on stop) + wled_state_before: Optional[dict] = None class ProcessorManager: @@ -401,6 +403,24 @@ class ProcessorManager: # Resolve stream settings self._resolve_stream_settings(state) + # Snapshot WLED state before streaming changes it + try: + async with httpx.AsyncClient(timeout=5) as http: + resp = await http.get(f"{state.device_url}/json/state") + resp.raise_for_status() + wled_state = resp.json() + state.wled_state_before = { + "on": wled_state.get("on", True), + "lor": wled_state.get("lor", 0), + } + # AudioReactive is optional (usermod) + if "AudioReactive" in wled_state: + state.wled_state_before["AudioReactive"] = wled_state["AudioReactive"] + logger.info(f"Saved WLED state before streaming: {state.wled_state_before}") + except Exception as e: + logger.warning(f"Could not snapshot WLED state: {e}") + state.wled_state_before = None + # Connect to WLED device try: use_ddp = state.led_count > 500 @@ -488,6 +508,19 @@ class ProcessorManager: pass state.task = None + # Restore WLED state that was changed when streaming started + if state.wled_state_before: + try: + async with httpx.AsyncClient(timeout=5) as http: + await http.post( + f"{state.device_url}/json/state", + json=state.wled_state_before, + ) + logger.info(f"Restored WLED state: {state.wled_state_before}") + except Exception as e: + logger.warning(f"Could not restore WLED state: {e}") + state.wled_state_before = None + # Close WLED connection if state.wled_client: await state.wled_client.close() diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 5562efd..84ab5cb 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -81,11 +81,28 @@ 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 = ''; + const img = document.getElementById('lightbox-image'); + // Revoke blob URL if one was used + if (img.src.startsWith('blob:')) URL.revokeObjectURL(img.src); + img.src = ''; document.getElementById('lightbox-stats').style.display = 'none'; unlockBody(); } +async function openFullImageLightbox(imageSource) { + try { + const resp = await fetch(`${API_BASE}/picture-streams/full-image?source=${encodeURIComponent(imageSource)}`, { + headers: getHeaders() + }); + if (!resp.ok) return; + const blob = await resp.blob(); + const blobUrl = URL.createObjectURL(blob); + openLightbox(blobUrl); + } catch (err) { + console.error('Failed to load full image:', err); + } +} + document.addEventListener('keydown', (e) => { if (e.key === 'Escape') { // Close in order: overlay lightboxes first, then modals @@ -3627,6 +3644,8 @@ async function validateStaticImage() { _lastValidatedImageSource = source; if (data.valid) { previewImg.src = data.preview; + previewImg.style.cursor = 'pointer'; + previewImg.onclick = () => openFullImageLightbox(source); infoEl.textContent = `${data.width} × ${data.height} px`; previewContainer.style.display = ''; statusEl.textContent = t('streams.validate_image.valid');