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 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,

View File

@@ -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()

View File

@@ -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');