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:
@@ -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,
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|||||||
Reference in New Issue
Block a user