Add full-image lightbox and restore WLED state on stop

- Add GET /picture-streams/full-image endpoint to serve full-res images
- Click static image preview thumbnail to open full-res lightbox
- Snapshot WLED state (on/off, lor, AudioReactive) before streaming
- Restore saved WLED state when streaming stops

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-12 03:06:59 +03:00
parent 66eecdb3c9
commit 472acd700a
3 changed files with 87 additions and 2 deletions

View File

@@ -10,7 +10,8 @@ from typing import List, Dict, Any
import httpx import httpx
import numpy as np import numpy as np
from PIL import Image 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 import __version__
from wled_controller.api.auth import AuthRequired from wled_controller.api.auth import AuthRequired
@@ -1612,6 +1613,38 @@ async def validate_image(
return ImageValidateResponse(valid=False, error=str(e)) 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) @router.post("/api/v1/picture-streams", response_model=PictureStreamResponse, tags=["Picture Streams"], status_code=201)
async def create_picture_stream( async def create_picture_stream(
data: PictureStreamCreate, data: PictureStreamCreate,

View File

@@ -120,6 +120,8 @@ class ProcessorState:
# Capture libraries (BetterCam, MSS, DXcam) use thread-local state, # Capture libraries (BetterCam, MSS, DXcam) use thread-local state,
# so all calls must run on the same thread. # so all calls must run on the same thread.
capture_executor: Optional[concurrent.futures.ThreadPoolExecutor] = None 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: class ProcessorManager:
@@ -401,6 +403,24 @@ class ProcessorManager:
# Resolve stream settings # Resolve stream settings
self._resolve_stream_settings(state) 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 # Connect to WLED device
try: try:
use_ddp = state.led_count > 500 use_ddp = state.led_count > 500
@@ -488,6 +508,19 @@ class ProcessorManager:
pass pass
state.task = None 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 # Close WLED connection
if state.wled_client: if state.wled_client:
await state.wled_client.close() await state.wled_client.close()

View File

@@ -81,11 +81,28 @@ function closeLightbox(event) {
if (event && event.target && event.target.closest('.lightbox-content')) return; if (event && event.target && event.target.closest('.lightbox-content')) return;
const lightbox = document.getElementById('image-lightbox'); const lightbox = document.getElementById('image-lightbox');
lightbox.classList.remove('active'); 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'; document.getElementById('lightbox-stats').style.display = 'none';
unlockBody(); 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) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
// Close in order: overlay lightboxes first, then modals // Close in order: overlay lightboxes first, then modals
@@ -3627,6 +3644,8 @@ async function validateStaticImage() {
_lastValidatedImageSource = source; _lastValidatedImageSource = source;
if (data.valid) { if (data.valid) {
previewImg.src = data.preview; previewImg.src = data.preview;
previewImg.style.cursor = 'pointer';
previewImg.onclick = () => openFullImageLightbox(source);
infoEl.textContent = `${data.width} × ${data.height} px`; infoEl.textContent = `${data.width} × ${data.height} px`;
previewContainer.style.display = ''; previewContainer.style.display = '';
statusEl.textContent = t('streams.validate_image.valid'); statusEl.textContent = t('streams.validate_image.valid');