From e0877a9b16da17703e59519b2fcad9d8a9783124 Mon Sep 17 00:00:00 2001 From: "alexei.dolgolyov" Date: Wed, 11 Feb 2026 19:57:43 +0300 Subject: [PATCH] Add static image picture stream type with auto-validating UI Introduces a new "static_image" stream type that loads a frame from a URL or local file path, enabling LED testing with known images or displaying static content. Includes validate-image API endpoint, auto-validation on blur/enter/paste with caching, capture template names on stream cards, and conditional test stats display for single-frame results. Co-Authored-By: Claude Opus 4.6 --- server/src/wled_controller/api/routes.py | 323 +++++++++++------- server/src/wled_controller/api/schemas.py | 23 +- .../wled_controller/core/processor_manager.py | 107 ++++-- server/src/wled_controller/static/app.js | 183 ++++++++-- server/src/wled_controller/static/index.html | 14 + .../wled_controller/static/locales/en.json | 12 +- .../wled_controller/static/locales/ru.json | 12 +- server/src/wled_controller/static/style.css | 42 +++ .../wled_controller/storage/picture_stream.py | 8 +- .../storage/picture_stream_store.py | 23 +- 10 files changed, 566 insertions(+), 181 deletions(-) diff --git a/server/src/wled_controller/api/routes.py b/server/src/wled_controller/api/routes.py index 8c60d05..eaa706e 100644 --- a/server/src/wled_controller/api/routes.py +++ b/server/src/wled_controller/api/routes.py @@ -54,6 +54,8 @@ from wled_controller.api.schemas import ( PictureStreamListResponse, PictureStreamTestRequest, PPTemplateTestRequest, + ImageValidateRequest, + ImageValidateResponse, ) from wled_controller.config import get_config from wled_controller.core.processor_manager import ProcessorManager, ProcessingSettings @@ -1284,69 +1286,87 @@ async def test_pp_template( raw_stream = chain["raw_stream"] - # Get capture template from raw stream - try: - capture_template = template_store.get_template(raw_stream.capture_template_id) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"Capture template not found: {raw_stream.capture_template_id}", - ) + if raw_stream.stream_type == "static_image": + # Static image: load directly + from pathlib import Path - display_index = raw_stream.display_index + source = raw_stream.image_source + start_time = time.perf_counter() - # Validate engine - if capture_template.engine_type not in EngineRegistry.get_available_engines(): - raise HTTPException( - status_code=400, - detail=f"Engine '{capture_template.engine_type}' is not available on this system", - ) + if source.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(source) + resp.raise_for_status() + pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") + else: + path = Path(source) + if not path.exists(): + raise HTTPException(status_code=400, detail=f"Image file not found: {source}") + pil_image = Image.open(path).convert("RGB") - # Check display lock - locked_device_id = processor_manager.get_display_lock_info(display_index) - if locked_device_id: - try: - device = device_store.get_device(locked_device_id) - device_name = device.name - except Exception: - device_name = locked_device_id - raise HTTPException( - status_code=409, - detail=f"Display {display_index} is currently being captured by device '{device_name}'. " - f"Please stop the device processing before testing.", - ) - - # Create engine and run test - engine = EngineRegistry.create_engine(capture_template.engine_type, capture_template.engine_config) - - logger.info(f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_stream_id}") - - frame_count = 0 - total_capture_time = 0.0 - last_frame = None - - start_time = time.perf_counter() - end_time = start_time + test_request.capture_duration - - while time.perf_counter() < end_time: - capture_start = time.perf_counter() - screen_capture = engine.capture_display(display_index) - capture_elapsed = time.perf_counter() - capture_start - - total_capture_time += capture_elapsed - frame_count += 1 - last_frame = screen_capture - - actual_duration = time.perf_counter() - start_time - - if last_frame is None: - raise RuntimeError("No frames captured during test") - - # Convert to PIL Image - if isinstance(last_frame.image, np.ndarray): - pil_image = Image.fromarray(last_frame.image) + actual_duration = time.perf_counter() - start_time + frame_count = 1 + total_capture_time = actual_duration else: - raise ValueError("Unexpected image format from engine") + # Raw capture stream: use engine + try: + capture_template = template_store.get_template(raw_stream.capture_template_id) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Capture template not found: {raw_stream.capture_template_id}", + ) + + display_index = raw_stream.display_index + + if capture_template.engine_type not in EngineRegistry.get_available_engines(): + raise HTTPException( + status_code=400, + detail=f"Engine '{capture_template.engine_type}' is not available on this system", + ) + + locked_device_id = processor_manager.get_display_lock_info(display_index) + if locked_device_id: + try: + device = device_store.get_device(locked_device_id) + device_name = device.name + except Exception: + device_name = locked_device_id + raise HTTPException( + status_code=409, + detail=f"Display {display_index} is currently being captured by device '{device_name}'. " + f"Please stop the device processing before testing.", + ) + + engine = EngineRegistry.create_engine(capture_template.engine_type, capture_template.engine_config) + + logger.info(f"Starting {test_request.capture_duration}s PP template test for {template_id} using stream {test_request.source_stream_id}") + + frame_count = 0 + total_capture_time = 0.0 + last_frame = None + + start_time = time.perf_counter() + end_time = start_time + test_request.capture_duration + + while time.perf_counter() < end_time: + capture_start = time.perf_counter() + screen_capture = engine.capture_display(display_index) + capture_elapsed = time.perf_counter() - capture_start + + total_capture_time += capture_elapsed + frame_count += 1 + last_frame = screen_capture + + actual_duration = time.perf_counter() - start_time + + if last_frame is None: + raise RuntimeError("No frames captured during test") + + if isinstance(last_frame.image, np.ndarray): + pil_image = Image.fromarray(last_frame.image) + else: + raise ValueError("Unexpected image format from engine") # Create thumbnail thumbnail_width = 640 @@ -1435,6 +1455,7 @@ def _stream_to_response(s) -> PictureStreamResponse: target_fps=s.target_fps, source_stream_id=s.source_stream_id, postprocessing_template_id=s.postprocessing_template_id, + image_source=s.image_source, created_at=s.created_at, updated_at=s.updated_at, description=s.description, @@ -1456,6 +1477,53 @@ async def list_picture_streams( raise HTTPException(status_code=500, detail=str(e)) +@router.post("/api/v1/picture-streams/validate-image", response_model=ImageValidateResponse, tags=["Picture Streams"]) +async def validate_image( + data: ImageValidateRequest, + _auth: AuthRequired, +): + """Validate an image source (URL or file path) and return a preview thumbnail.""" + try: + from pathlib import Path + + source = data.image_source.strip() + if not source: + return ImageValidateResponse(valid=False, error="Image source is empty") + + 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(): + return ImageValidateResponse(valid=False, error=f"File not found: {source}") + pil_image = Image.open(path) + + pil_image = pil_image.convert("RGB") + width, height = pil_image.size + + # Create thumbnail preview (max 320px wide) + thumb = pil_image.copy() + thumb.thumbnail((320, 320), Image.Resampling.LANCZOS) + buf = io.BytesIO() + thumb.save(buf, format="JPEG", quality=80) + buf.seek(0) + preview = f"data:image/jpeg;base64,{base64.b64encode(buf.getvalue()).decode()}" + + return ImageValidateResponse( + valid=True, width=width, height=height, preview=preview + ) + + except httpx.HTTPStatusError as e: + return ImageValidateResponse(valid=False, error=f"HTTP {e.response.status_code}: {e.response.reason_phrase}") + except httpx.RequestError as e: + return ImageValidateResponse(valid=False, error=f"Request failed: {e}") + except Exception as e: + return ImageValidateResponse(valid=False, error=str(e)) + + @router.post("/api/v1/picture-streams", response_model=PictureStreamResponse, tags=["Picture Streams"], status_code=201) async def create_picture_stream( data: PictureStreamCreate, @@ -1493,6 +1561,7 @@ async def create_picture_stream( target_fps=data.target_fps, source_stream_id=data.source_stream_id, postprocessing_template_id=data.postprocessing_template_id, + image_source=data.image_source, description=data.description, ) return _stream_to_response(stream) @@ -1536,6 +1605,7 @@ async def update_picture_stream( target_fps=data.target_fps, source_stream_id=data.source_stream_id, postprocessing_template_id=data.postprocessing_template_id, + image_source=data.image_source, description=data.description, ) return _stream_to_response(stream) @@ -1600,69 +1670,88 @@ async def test_picture_stream( raw_stream = chain["raw_stream"] - # Get capture template from raw stream - try: - capture_template = template_store.get_template(raw_stream.capture_template_id) - except ValueError: - raise HTTPException( - status_code=400, - detail=f"Capture template not found: {raw_stream.capture_template_id}", - ) + if raw_stream.stream_type == "static_image": + # Static image stream: load image directly, no engine needed + from pathlib import Path - display_index = raw_stream.display_index + source = raw_stream.image_source + start_time = time.perf_counter() - # Validate engine - if capture_template.engine_type not in EngineRegistry.get_available_engines(): - raise HTTPException( - status_code=400, - detail=f"Engine '{capture_template.engine_type}' is not available on this system", - ) + if source.startswith(("http://", "https://")): + async with httpx.AsyncClient(timeout=15, follow_redirects=True) as client: + resp = await client.get(source) + resp.raise_for_status() + pil_image = Image.open(io.BytesIO(resp.content)).convert("RGB") + else: + path = Path(source) + if not path.exists(): + raise HTTPException(status_code=400, detail=f"Image file not found: {source}") + pil_image = Image.open(path).convert("RGB") - # Check display lock - locked_device_id = processor_manager.get_display_lock_info(display_index) - if locked_device_id: - try: - device = device_store.get_device(locked_device_id) - device_name = device.name - except Exception: - device_name = locked_device_id - raise HTTPException( - status_code=409, - detail=f"Display {display_index} is currently being captured by device '{device_name}'. " - f"Please stop the device processing before testing.", - ) + actual_duration = time.perf_counter() - start_time + frame_count = 1 + total_capture_time = actual_duration - # Create engine and run test - engine = EngineRegistry.create_engine(capture_template.engine_type, capture_template.engine_config) - - logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}") - - frame_count = 0 - total_capture_time = 0.0 - last_frame = None - - start_time = time.perf_counter() - end_time = start_time + test_request.capture_duration - - while time.perf_counter() < end_time: - capture_start = time.perf_counter() - screen_capture = engine.capture_display(display_index) - capture_elapsed = time.perf_counter() - capture_start - - total_capture_time += capture_elapsed - frame_count += 1 - last_frame = screen_capture - - actual_duration = time.perf_counter() - start_time - - if last_frame is None: - raise RuntimeError("No frames captured during test") - - # Convert to PIL Image - if isinstance(last_frame.image, np.ndarray): - pil_image = Image.fromarray(last_frame.image) else: - raise ValueError("Unexpected image format from engine") + # Raw capture stream: use engine + try: + capture_template = template_store.get_template(raw_stream.capture_template_id) + except ValueError: + raise HTTPException( + status_code=400, + detail=f"Capture template not found: {raw_stream.capture_template_id}", + ) + + display_index = raw_stream.display_index + + if capture_template.engine_type not in EngineRegistry.get_available_engines(): + raise HTTPException( + status_code=400, + detail=f"Engine '{capture_template.engine_type}' is not available on this system", + ) + + locked_device_id = processor_manager.get_display_lock_info(display_index) + if locked_device_id: + try: + device = device_store.get_device(locked_device_id) + device_name = device.name + except Exception: + device_name = locked_device_id + raise HTTPException( + status_code=409, + detail=f"Display {display_index} is currently being captured by device '{device_name}'. " + f"Please stop the device processing before testing.", + ) + + engine = EngineRegistry.create_engine(capture_template.engine_type, capture_template.engine_config) + + logger.info(f"Starting {test_request.capture_duration}s stream test for {stream_id}") + + frame_count = 0 + total_capture_time = 0.0 + last_frame = None + + start_time = time.perf_counter() + end_time = start_time + test_request.capture_duration + + while time.perf_counter() < end_time: + capture_start = time.perf_counter() + screen_capture = engine.capture_display(display_index) + capture_elapsed = time.perf_counter() - capture_start + + total_capture_time += capture_elapsed + frame_count += 1 + last_frame = screen_capture + + actual_duration = time.perf_counter() - start_time + + if last_frame is None: + raise RuntimeError("No frames captured during test") + + if isinstance(last_frame.image, np.ndarray): + pil_image = Image.fromarray(last_frame.image) + else: + raise ValueError("Unexpected image format from engine") # Create thumbnail thumbnail_width = 640 diff --git a/server/src/wled_controller/api/schemas.py b/server/src/wled_controller/api/schemas.py index 244110a..b42d2b8 100644 --- a/server/src/wled_controller/api/schemas.py +++ b/server/src/wled_controller/api/schemas.py @@ -405,12 +405,13 @@ class PictureStreamCreate(BaseModel): """Request to create a picture stream.""" name: str = Field(description="Stream name", min_length=1, max_length=100) - stream_type: Literal["raw", "processed"] = Field(description="Stream type") + stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type") display_index: Optional[int] = Field(None, description="Display index (raw streams)", ge=0) capture_template_id: Optional[str] = Field(None, description="Capture template ID (raw streams)") target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90) source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)") postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") + image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") description: Optional[str] = Field(None, description="Stream description", max_length=500) @@ -423,6 +424,7 @@ class PictureStreamUpdate(BaseModel): target_fps: Optional[int] = Field(None, description="Target FPS (raw streams)", ge=10, le=90) source_stream_id: Optional[str] = Field(None, description="Source stream ID (processed streams)") postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID (processed streams)") + image_source: Optional[str] = Field(None, description="Image URL or file path (static_image streams)") description: Optional[str] = Field(None, description="Stream description", max_length=500) @@ -431,12 +433,13 @@ class PictureStreamResponse(BaseModel): id: str = Field(description="Stream ID") name: str = Field(description="Stream name") - stream_type: str = Field(description="Stream type (raw or processed)") + stream_type: str = Field(description="Stream type (raw, processed, or static_image)") display_index: Optional[int] = Field(None, description="Display index") capture_template_id: Optional[str] = Field(None, description="Capture template ID") target_fps: Optional[int] = Field(None, description="Target FPS") source_stream_id: Optional[str] = Field(None, description="Source stream ID") postprocessing_template_id: Optional[str] = Field(None, description="Postprocessing template ID") + image_source: Optional[str] = Field(None, description="Image URL or file path") created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Stream description") @@ -461,3 +464,19 @@ class PPTemplateTestRequest(BaseModel): source_stream_id: str = Field(description="ID of the source picture stream to capture from") capture_duration: float = Field(default=5.0, ge=1.0, le=30.0, description="Duration to capture in seconds") + + +class ImageValidateRequest(BaseModel): + """Request to validate an image source (URL or file path).""" + + image_source: str = Field(description="Image URL or local file path") + + +class ImageValidateResponse(BaseModel): + """Response from image validation.""" + + valid: bool = Field(description="Whether the image source is accessible and valid") + width: Optional[int] = Field(None, description="Image width in pixels") + height: Optional[int] = Field(None, description="Image height in pixels") + preview: Optional[str] = Field(None, description="Base64-encoded JPEG thumbnail") + error: Optional[str] = Field(None, description="Error message if invalid") diff --git a/server/src/wled_controller/core/processor_manager.py b/server/src/wled_controller/core/processor_manager.py index 48bd2bc..4a32448 100644 --- a/server/src/wled_controller/core/processor_manager.py +++ b/server/src/wled_controller/core/processor_manager.py @@ -13,7 +13,10 @@ from wled_controller.core.calibration import ( PixelMapper, create_default_calibration, ) +import numpy as np + from wled_controller.core.capture_engines import CaptureEngine, EngineRegistry +from wled_controller.core.capture_engines.base import ScreenCapture from wled_controller.core.filters import FilterInstance, FilterRegistry, ImagePool, PostprocessingFilter from wled_controller.core.pixel_processor import smooth_colors from wled_controller.core.screen_capture import extract_border_pixels @@ -108,6 +111,8 @@ class ProcessorState: resolved_engine_type: Optional[str] = None resolved_engine_config: Optional[dict] = None resolved_filters: Optional[List[FilterInstance]] = None + # Static image: cached frame for static_image streams (no engine needed) + static_image: Optional[np.ndarray] = None image_pool: Optional[ImagePool] = None filter_instances: Optional[List[PostprocessingFilter]] = None @@ -280,19 +285,28 @@ class ProcessorManager: raw_stream = chain["raw_stream"] pp_template_ids = chain["postprocessing_template_ids"] - state.resolved_display_index = raw_stream.display_index - state.resolved_target_fps = raw_stream.target_fps + if raw_stream.stream_type == "static_image": + # Static image stream: load image once, no engine needed + state.resolved_display_index = -1 + state.resolved_target_fps = 1 + state.resolved_engine_type = None + state.resolved_engine_config = None + state.static_image = self._load_static_image(raw_stream.image_source) + else: + # Raw capture stream + state.resolved_display_index = raw_stream.display_index + state.resolved_target_fps = raw_stream.target_fps - # Resolve capture engine from raw stream's capture template - if raw_stream.capture_template_id and self._capture_template_store: - try: - tpl = self._capture_template_store.get_template(raw_stream.capture_template_id) - state.resolved_engine_type = tpl.engine_type - state.resolved_engine_config = tpl.engine_config - except ValueError: - logger.warning(f"Capture template {raw_stream.capture_template_id} not found, using MSS fallback") - state.resolved_engine_type = "mss" - state.resolved_engine_config = {} + # Resolve capture engine from raw stream's capture template + if raw_stream.capture_template_id and self._capture_template_store: + try: + tpl = self._capture_template_store.get_template(raw_stream.capture_template_id) + state.resolved_engine_type = tpl.engine_type + state.resolved_engine_config = tpl.engine_config + except ValueError: + logger.warning(f"Capture template {raw_stream.capture_template_id} not found, using MSS fallback") + state.resolved_engine_type = "mss" + state.resolved_engine_config = {} # Resolve postprocessing: use first PP template in chain if pp_template_ids and self._pp_template_store: @@ -337,6 +351,27 @@ class ProcessorManager: state.resolved_engine_type = "mss" state.resolved_engine_config = {} + @staticmethod + def _load_static_image(image_source: str) -> np.ndarray: + """Load a static image from URL or file path, return as RGB numpy array.""" + from io import BytesIO + from pathlib import Path + + from PIL import Image + + if image_source.startswith(("http://", "https://")): + response = httpx.get(image_source, timeout=15.0, follow_redirects=True) + response.raise_for_status() + pil_image = Image.open(BytesIO(response.content)) + else: + path = Path(image_source) + if not path.exists(): + raise FileNotFoundError(f"Image file not found: {image_source}") + pil_image = Image.open(path) + + pil_image = pil_image.convert("RGB") + return np.array(pil_image) + async def start_processing(self, device_id: str): """Start screen processing for a device. @@ -373,19 +408,22 @@ class ProcessorManager: logger.error(f"Failed to connect to WLED device {device_id}: {e}") raise RuntimeError(f"Failed to connect to WLED device: {e}") - # Initialize capture engine from resolved settings - try: - engine_type = state.resolved_engine_type or "mss" - engine_config = state.resolved_engine_config or {} - engine = EngineRegistry.create_engine(engine_type, engine_config) - engine.initialize() - state.capture_engine = engine - logger.info(f"Initialized capture engine for device {device_id}: {engine_type}") - except Exception as e: - logger.error(f"Failed to initialize capture engine for device {device_id}: {e}") - if state.wled_client: - await state.wled_client.disconnect() - raise RuntimeError(f"Failed to initialize capture engine: {e}") + # Initialize capture engine from resolved settings (skip for static_image) + if state.static_image is not None: + logger.info(f"Using static image for device {device_id} ({state.static_image.shape[1]}x{state.static_image.shape[0]})") + else: + try: + engine_type = state.resolved_engine_type or "mss" + engine_config = state.resolved_engine_config or {} + engine = EngineRegistry.create_engine(engine_type, engine_config) + engine.initialize() + state.capture_engine = engine + logger.info(f"Initialized capture engine for device {device_id}: {engine_type}") + except Exception as e: + logger.error(f"Failed to initialize capture engine for device {device_id}: {e}") + if state.wled_client: + await state.wled_client.disconnect() + raise RuntimeError(f"Failed to initialize capture engine: {e}") # Initialize pixel mapper state.pixel_mapper = PixelMapper( @@ -443,6 +481,9 @@ class ProcessorManager: state.capture_engine.cleanup() state.capture_engine = None + # Release cached static image + state.static_image = None + logger.info(f"Stopped processing for device {device_id}") async def _processing_loop(self, device_id: str): @@ -502,11 +543,17 @@ class ProcessorManager: continue try: - # Capture screen using engine - capture = await asyncio.to_thread( - state.capture_engine.capture_display, - display_index - ) + # Get frame: static image or live capture + if state.static_image is not None: + h, w = state.static_image.shape[:2] + capture = ScreenCapture( + image=state.static_image.copy(), width=w, height=h, display_index=-1 + ) + else: + capture = await asyncio.to_thread( + state.capture_engine.capture_display, + display_index + ) # Apply postprocessing filters to the full captured image if filter_objects: diff --git a/server/src/wled_controller/static/app.js b/server/src/wled_controller/static/app.js index 0624769..54a28d3 100644 --- a/server/src/wled_controller/static/app.js +++ b/server/src/wled_controller/static/app.js @@ -2953,13 +2953,17 @@ async function runTemplateTest() { function buildTestStatsHtml(result) { const p = result.performance; const res = `${result.full_capture.width}x${result.full_capture.height}`; - return ` + let html = `
${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.frame_count')}: ${p.frame_count}
`; + if (p.frame_count > 1) { + html += `
${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}
- `; +
${t('templates.test.results.avg_capture_time')}: ${p.avg_capture_time_ms.toFixed(1)}ms
`; + } + html += ` +
Resolution: ${res}
`; + return html; } // Display test results — opens lightbox with stats overlay @@ -3046,20 +3050,27 @@ async function deleteTemplate(templateId) { let _cachedStreams = []; let _cachedPPTemplates = []; +let _cachedCaptureTemplates = []; let _availableFilters = []; // Loaded from GET /filters async function loadPictureStreams() { try { - // Ensure PP templates are cached so processed stream cards can show filter info - if (_cachedPPTemplates.length === 0) { + // Ensure PP templates and capture templates are cached for stream card display + if (_cachedPPTemplates.length === 0 || _cachedCaptureTemplates.length === 0) { try { if (_availableFilters.length === 0) { const fr = await fetchWithAuth('/filters'); if (fr.ok) { const fd = await fr.json(); _availableFilters = fd.filters || []; } } - const pr = await fetchWithAuth('/postprocessing-templates'); - if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; } - } catch (e) { console.warn('Could not pre-load PP templates for streams:', e); } + if (_cachedPPTemplates.length === 0) { + const pr = await fetchWithAuth('/postprocessing-templates'); + if (pr.ok) { const pd = await pr.json(); _cachedPPTemplates = pd.templates || []; } + } + if (_cachedCaptureTemplates.length === 0) { + const cr = await fetchWithAuth('/capture-templates'); + if (cr.ok) { const cd = await cr.json(); _cachedCaptureTemplates = cd.templates || []; } + } + } catch (e) { console.warn('Could not pre-load templates for streams:', e); } } const response = await fetchWithAuth('/picture-streams'); if (!response.ok) { @@ -3094,6 +3105,19 @@ function renderPictureStreamsList(streams) { +
+
+ 🖼️ + ${t('streams.group.static_image')} + 0 +
+
+
+
+
+
${t('streams.add.static_image')}
+
+
+
🎨 @@ -3111,13 +3135,24 @@ function renderPictureStreamsList(streams) { } const renderCard = (stream) => { - const typeIcon = stream.stream_type === 'raw' ? '🖥️' : '🎨'; - const typeBadge = stream.stream_type === 'raw' - ? `${t('streams.type.raw')}` - : `${t('streams.type.processed')}`; + const typeIcons = { raw: '🖥️', processed: '🎨', static_image: '🖼️' }; + const typeIcon = typeIcons[stream.stream_type] || '📺'; + const typeBadges = { + raw: `${t('streams.type.raw')}`, + processed: `${t('streams.type.processed')}`, + static_image: `${t('streams.type.static_image')}`, + }; + const typeBadge = typeBadges[stream.stream_type] || ''; let detailsHtml = ''; if (stream.stream_type === 'raw') { + let captureTemplateHtml = ''; + if (stream.capture_template_id) { + const capTmpl = _cachedCaptureTemplates.find(t => t.id === stream.capture_template_id); + if (capTmpl) { + captureTemplateHtml = `
${t('streams.capture_template')} ${escapeHtml(capTmpl.name)}
`; + } + } detailsHtml = `
${t('streams.display')} ${stream.display_index ?? 0} @@ -3125,8 +3160,9 @@ function renderPictureStreamsList(streams) {
${t('streams.target_fps')} ${stream.target_fps ?? 30}
+ ${captureTemplateHtml} `; - } else { + } else if (stream.stream_type === 'processed') { // Find source stream name and PP template name const sourceStream = _cachedStreams.find(s => s.id === stream.source_stream_id); const sourceName = sourceStream ? escapeHtml(sourceStream.name) : (stream.source_stream_id || '-'); @@ -3144,6 +3180,15 @@ function renderPictureStreamsList(streams) {
${ppTemplateHtml} `; + } else if (stream.stream_type === 'static_image') { + const src = stream.image_source || ''; + const truncated = src.length > 50 ? src.substring(0, 47) + '...' : src; + detailsHtml = ` +
+ ${t('streams.image_source')} +
+
${escapeHtml(truncated)}
+ `; } return ` @@ -3171,6 +3216,7 @@ function renderPictureStreamsList(streams) { const rawStreams = streams.filter(s => s.stream_type === 'raw'); const processedStreams = streams.filter(s => s.stream_type === 'processed'); + const staticImageStreams = streams.filter(s => s.stream_type === 'static_image'); let html = ''; @@ -3190,6 +3236,22 @@ function renderPictureStreamsList(streams) {
`; + // Static Image streams section + html += `
+
+ 🖼️ + ${t('streams.group.static_image')} + ${staticImageStreams.length} +
+
+ ${staticImageStreams.map(renderCard).join('')} +
+
+
+
${t('streams.add.static_image')}
+
+
+
`; + // Processed streams section html += `
@@ -3213,18 +3275,28 @@ function onStreamTypeChange() { const streamType = document.getElementById('stream-type').value; document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none'; document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none'; + document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none'; } 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); + const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' }; + document.getElementById('stream-modal-title').textContent = t(titleKeys[streamType] || 'streams.add'); document.getElementById('stream-form').reset(); document.getElementById('stream-id').value = ''; document.getElementById('stream-display-index').value = ''; document.getElementById('stream-display-picker-label').textContent = t('displays.picker.select'); document.getElementById('stream-error').style.display = 'none'; document.getElementById('stream-type').value = streamType; + // Clear static image preview and wire up auto-validation + _lastValidatedImageSource = ''; + const imgSrcInput = document.getElementById('stream-image-source'); + imgSrcInput.value = ''; + document.getElementById('stream-image-preview-container').style.display = 'none'; + document.getElementById('stream-image-validation-status').style.display = 'none'; + imgSrcInput.onblur = () => validateStaticImage(); + imgSrcInput.onkeydown = (e) => { if (e.key === 'Enter') { e.preventDefault(); validateStaticImage(); } }; + imgSrcInput.onpaste = () => setTimeout(() => validateStaticImage(), 0); onStreamTypeChange(); // Populate dropdowns @@ -3242,8 +3314,8 @@ async function editStream(streamId) { if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`); const stream = await response.json(); - const editTitleKey = stream.stream_type === 'raw' ? 'streams.edit.raw' : 'streams.edit.processed'; - document.getElementById('stream-modal-title').textContent = t(editTitleKey); + const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' }; + document.getElementById('stream-modal-title').textContent = t(editTitleKeys[stream.stream_type] || 'streams.edit'); document.getElementById('stream-id').value = streamId; document.getElementById('stream-name').value = stream.name; document.getElementById('stream-description').value = stream.description || ''; @@ -3251,6 +3323,9 @@ async function editStream(streamId) { // Set type (hidden input) document.getElementById('stream-type').value = stream.stream_type; + // Clear static image preview + document.getElementById('stream-image-preview-container').style.display = 'none'; + document.getElementById('stream-image-validation-status').style.display = 'none'; onStreamTypeChange(); // Populate dropdowns before setting values @@ -3264,9 +3339,15 @@ async function editStream(streamId) { const fps = stream.target_fps ?? 30; document.getElementById('stream-target-fps').value = fps; document.getElementById('stream-target-fps-value').textContent = fps; - } else { + } else if (stream.stream_type === 'processed') { document.getElementById('stream-source').value = stream.source_stream_id || ''; document.getElementById('stream-pp-template').value = stream.postprocessing_template_id || ''; + } else if (stream.stream_type === 'static_image') { + document.getElementById('stream-image-source').value = stream.image_source || ''; + // Auto-validate to show preview + if (stream.image_source) { + validateStaticImage(); + } } const modal = document.getElementById('stream-modal'); @@ -3324,7 +3405,8 @@ async function populateStreamModalDropdowns() { if (s.id === editingId) return; const opt = document.createElement('option'); opt.value = s.id; - const typeLabel = s.stream_type === 'raw' ? '🖥️' : '🎨'; + const typeLabels = { raw: '🖥️', processed: '🎨', static_image: '🖼️' }; + const typeLabel = typeLabels[s.stream_type] || '📺'; opt.textContent = `${typeLabel} ${s.name}`; sourceSelect.appendChild(opt); }); @@ -3367,9 +3449,16 @@ async function saveStream() { payload.display_index = parseInt(document.getElementById('stream-display-index').value) || 0; payload.capture_template_id = document.getElementById('stream-capture-template').value; payload.target_fps = parseInt(document.getElementById('stream-target-fps').value) || 30; - } else { + } else if (streamType === 'processed') { payload.source_stream_id = document.getElementById('stream-source').value; payload.postprocessing_template_id = document.getElementById('stream-pp-template').value; + } else if (streamType === 'static_image') { + const imageSource = document.getElementById('stream-image-source').value.trim(); + if (!imageSource) { + showToast(t('streams.error.required'), 'error'); + return; + } + payload.image_source = imageSource; } try { @@ -3429,6 +3518,56 @@ function closeStreamModal() { unlockBody(); } +let _lastValidatedImageSource = ''; + +async function validateStaticImage() { + const source = document.getElementById('stream-image-source').value.trim(); + const previewContainer = document.getElementById('stream-image-preview-container'); + const previewImg = document.getElementById('stream-image-preview'); + const infoEl = document.getElementById('stream-image-info'); + const statusEl = document.getElementById('stream-image-validation-status'); + + if (!source) { + _lastValidatedImageSource = ''; + previewContainer.style.display = 'none'; + statusEl.style.display = 'none'; + return; + } + + if (source === _lastValidatedImageSource) return; + + // Show loading state + statusEl.textContent = t('streams.validate_image.validating'); + statusEl.className = 'validation-status loading'; + statusEl.style.display = 'block'; + previewContainer.style.display = 'none'; + + try { + const response = await fetchWithAuth('/picture-streams/validate-image', { + method: 'POST', + body: JSON.stringify({ image_source: source }), + }); + const data = await response.json(); + + _lastValidatedImageSource = source; + if (data.valid) { + previewImg.src = data.preview; + infoEl.textContent = `${data.width} × ${data.height} px`; + previewContainer.style.display = ''; + statusEl.textContent = t('streams.validate_image.valid'); + statusEl.className = 'validation-status success'; + } else { + previewContainer.style.display = 'none'; + statusEl.textContent = `${t('streams.validate_image.invalid')}: ${data.error}`; + statusEl.className = 'validation-status error'; + } + } catch (err) { + previewContainer.style.display = 'none'; + statusEl.textContent = `${t('streams.validate_image.invalid')}: ${err.message}`; + statusEl.className = 'validation-status error'; + } +} + // ===== Picture Stream Test ===== let _currentTestStreamId = null; diff --git a/server/src/wled_controller/static/index.html b/server/src/wled_controller/static/index.html index 68e7783..92982ea 100644 --- a/server/src/wled_controller/static/index.html +++ b/server/src/wled_controller/static/index.html @@ -569,6 +569,20 @@
+ + +
diff --git a/server/src/wled_controller/static/locales/en.json b/server/src/wled_controller/static/locales/en.json index fce5cc6..ca53714 100644 --- a/server/src/wled_controller/static/locales/en.json +++ b/server/src/wled_controller/static/locales/en.json @@ -280,5 +280,15 @@ "device.stream_settings.interpolation_hint": "How to calculate LED color from sampled pixels", "device.stream_settings.smoothing": "Smoothing:", "device.stream_settings.smoothing_hint": "Temporal blending between frames (0=none, 1=full). Reduces flicker.", - "device.tip.stream_selector": "Configure picture stream and LED projection settings for this device" + "device.tip.stream_selector": "Configure picture stream and LED projection settings for this device", + "streams.group.static_image": "Static Image Streams", + "streams.add.static_image": "Add Static Image", + "streams.edit.static_image": "Edit Static Image", + "streams.type.static_image": "Static Image", + "streams.image_source": "Image Source:", + "streams.image_source.placeholder": "https://example.com/image.jpg or C:\\path\\to\\image.png", + "streams.image_source.hint": "Enter a URL (http/https) or local file path to an image", + "streams.validate_image.validating": "Validating...", + "streams.validate_image.valid": "Image accessible", + "streams.validate_image.invalid": "Image not accessible" } diff --git a/server/src/wled_controller/static/locales/ru.json b/server/src/wled_controller/static/locales/ru.json index 2a8593f..c20e559 100644 --- a/server/src/wled_controller/static/locales/ru.json +++ b/server/src/wled_controller/static/locales/ru.json @@ -280,5 +280,15 @@ "device.stream_settings.interpolation_hint": "Как вычислять цвет LED из выбранных пикселей", "device.stream_settings.smoothing": "Сглаживание:", "device.stream_settings.smoothing_hint": "Временное смешивание между кадрами (0=нет, 1=полное). Уменьшает мерцание.", - "device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства" + "device.tip.stream_selector": "Настройки видеопотока и проекции LED для этого устройства", + "streams.group.static_image": "Статические изображения", + "streams.add.static_image": "Добавить статическое изображение", + "streams.edit.static_image": "Редактировать статическое изображение", + "streams.type.static_image": "Статическое изображение", + "streams.image_source": "Источник изображения:", + "streams.image_source.placeholder": "https://example.com/image.jpg или C:\\path\\to\\image.png", + "streams.image_source.hint": "Введите URL (http/https) или локальный путь к изображению", + "streams.validate_image.validating": "Проверка...", + "streams.validate_image.valid": "Изображение доступно", + "streams.validate_image.invalid": "Изображение недоступно" } diff --git a/server/src/wled_controller/static/style.css b/server/src/wled_controller/static/style.css index 2bf48c7..71ab557 100644 --- a/server/src/wled_controller/static/style.css +++ b/server/src/wled_controller/static/style.css @@ -2360,3 +2360,45 @@ input:-webkit-autofill:focus { } } +/* Static image stream styles */ +.image-preview-container { + text-align: center; + margin: 12px 0; + padding: 12px; + background: var(--bg-secondary); + border-radius: 8px; + border: 1px solid var(--border-color); +} +.stream-image-preview { + max-width: 100%; + max-height: 200px; + border-radius: 4px; + border: 1px solid var(--border-color); +} +.stream-image-info { + font-size: 0.75rem; + color: var(--text-muted); + margin-top: 6px; +} +.validation-status { + font-size: 0.8rem; + margin-top: 4px; + padding: 4px 8px; + border-radius: 4px; +} +.validation-status.success { + color: #4caf50; +} +.validation-status.error { + color: #f44336; +} +.validation-status.loading { + color: var(--text-muted); +} +.stream-card-image-source { + font-size: 0.7rem; + color: var(--text-muted); + word-break: break-all; + margin-top: 4px; +} + diff --git a/server/src/wled_controller/storage/picture_stream.py b/server/src/wled_controller/storage/picture_stream.py index ff60d07..0b4b74d 100644 --- a/server/src/wled_controller/storage/picture_stream.py +++ b/server/src/wled_controller/storage/picture_stream.py @@ -12,11 +12,12 @@ class PictureStream: A picture stream is either: - "raw": captures from a display using a capture engine template at a target FPS - "processed": applies postprocessing to another picture stream + - "static_image": returns a static frame from a URL or local file path """ id: str name: str - stream_type: str # "raw" or "processed" + stream_type: str # "raw", "processed", or "static_image" created_at: datetime updated_at: datetime @@ -29,6 +30,9 @@ class PictureStream: source_stream_id: Optional[str] = None postprocessing_template_id: Optional[str] = None + # Static image fields (used when stream_type == "static_image") + image_source: Optional[str] = None + description: Optional[str] = None def to_dict(self) -> dict: @@ -42,6 +46,7 @@ class PictureStream: "target_fps": self.target_fps, "source_stream_id": self.source_stream_id, "postprocessing_template_id": self.postprocessing_template_id, + "image_source": self.image_source, "created_at": self.created_at.isoformat(), "updated_at": self.updated_at.isoformat(), "description": self.description, @@ -59,6 +64,7 @@ class PictureStream: target_fps=data.get("target_fps"), source_stream_id=data.get("source_stream_id"), postprocessing_template_id=data.get("postprocessing_template_id"), + image_source=data.get("image_source"), created_at=datetime.fromisoformat(data["created_at"]) if isinstance(data.get("created_at"), str) else data.get("created_at", datetime.utcnow()), diff --git a/server/src/wled_controller/storage/picture_stream_store.py b/server/src/wled_controller/storage/picture_stream_store.py index 788ccfd..99d15e6 100644 --- a/server/src/wled_controller/storage/picture_stream_store.py +++ b/server/src/wled_controller/storage/picture_stream_store.py @@ -105,7 +105,7 @@ class PictureStreamStore: current_stream = self._streams.get(current_id) if not current_stream: break - if current_stream.stream_type == "raw": + if current_stream.stream_type != "processed": break current_id = current_stream.source_stream_id @@ -134,24 +134,26 @@ class PictureStreamStore: target_fps: Optional[int] = None, source_stream_id: Optional[str] = None, postprocessing_template_id: Optional[str] = None, + image_source: Optional[str] = None, description: Optional[str] = None, ) -> PictureStream: """Create a new picture stream. Args: name: Stream name - stream_type: "raw" or "processed" + stream_type: "raw", "processed", or "static_image" display_index: Display index (raw streams) capture_template_id: Capture template ID (raw streams) target_fps: Target FPS (raw streams) source_stream_id: Source stream ID (processed streams) postprocessing_template_id: Postprocessing template ID (processed streams) + image_source: URL or file path (static_image streams) description: Optional description Raises: ValueError: If validation fails or cycle detected """ - if stream_type not in ("raw", "processed"): + if stream_type not in ("raw", "processed", "static_image"): raise ValueError(f"Invalid stream type: {stream_type}") if stream_type == "raw": @@ -172,6 +174,9 @@ class PictureStreamStore: # Check for cycles if self._detect_cycle(source_stream_id): raise ValueError("Cycle detected in stream chain") + elif stream_type == "static_image": + if not image_source: + raise ValueError("Static image streams require image_source") # Check for duplicate name for stream in self._streams.values(): @@ -190,6 +195,7 @@ class PictureStreamStore: target_fps=target_fps, source_stream_id=source_stream_id, postprocessing_template_id=postprocessing_template_id, + image_source=image_source, created_at=now, updated_at=now, description=description, @@ -210,6 +216,7 @@ class PictureStreamStore: target_fps: Optional[int] = None, source_stream_id: Optional[str] = None, postprocessing_template_id: Optional[str] = None, + image_source: Optional[str] = None, description: Optional[str] = None, ) -> PictureStream: """Update an existing picture stream. @@ -241,6 +248,8 @@ class PictureStreamStore: stream.source_stream_id = source_stream_id if postprocessing_template_id is not None: stream.postprocessing_template_id = postprocessing_template_id + if image_source is not None: + stream.image_source = image_source if description is not None: stream.description = description @@ -289,9 +298,9 @@ class PictureStreamStore: return False def resolve_stream_chain(self, stream_id: str) -> dict: - """Resolve a stream chain to get the final raw stream and collected postprocessing templates. + """Resolve a stream chain to get the terminal stream and collected postprocessing templates. - Walks the chain from the given stream to the root raw stream, + Walks the chain from the given stream to a terminal stream (raw or static_image), collecting postprocessing template IDs along the way. Args: @@ -299,7 +308,7 @@ class PictureStreamStore: Returns: Dict with: - - raw_stream: The root raw PictureStream + - raw_stream: The terminal PictureStream (raw or static_image) - postprocessing_template_ids: List of PP template IDs (in chain order) Raises: @@ -316,7 +325,7 @@ class PictureStreamStore: stream = self.get_stream(current_id) - if stream.stream_type == "raw": + if stream.stream_type != "processed": return { "raw_stream": stream, "postprocessing_template_ids": postprocessing_template_ids,