diff --git a/server/src/wled_controller/api/routes/_test_helpers.py b/server/src/wled_controller/api/routes/_test_helpers.py index 0af52e1..8407d0c 100644 --- a/server/src/wled_controller/api/routes/_test_helpers.py +++ b/server/src/wled_controller/api/routes/_test_helpers.py @@ -43,6 +43,19 @@ def _encode_jpeg(pil_image: Image.Image, quality: int = 85) -> str: return f"data:image/jpeg;base64,{b64}" +def encode_preview_frame(image: np.ndarray, max_width: int = None, quality: int = 80) -> bytes: + """Encode a numpy RGB image to JPEG bytes, optionally downscaling.""" + import cv2 + if max_width and image.shape[1] > max_width: + scale = max_width / image.shape[1] + new_h = int(image.shape[0] * scale) + image = cv2.resize(image, (max_width, new_h), interpolation=cv2.INTER_AREA) + # RGB → BGR for OpenCV JPEG encoding + bgr = cv2.cvtColor(image, cv2.COLOR_RGB2BGR) + _, buf = cv2.imencode('.jpg', bgr, [cv2.IMWRITE_JPEG_QUALITY, quality]) + return buf.tobytes() + + def _make_thumbnail(pil_image: Image.Image, max_width: int) -> Image.Image: """Create a thumbnail copy of the image, preserving aspect ratio.""" thumb = pil_image.copy() diff --git a/server/src/wled_controller/api/routes/picture_sources.py b/server/src/wled_controller/api/routes/picture_sources.py index bb64007..3d916ea 100644 --- a/server/src/wled_controller/api/routes/picture_sources.py +++ b/server/src/wled_controller/api/routes/picture_sources.py @@ -39,7 +39,7 @@ from wled_controller.storage.output_target_store import OutputTargetStore from wled_controller.storage.template_store import TemplateStore from wled_controller.storage.postprocessing_template_store import PostprocessingTemplateStore from wled_controller.storage.picture_source_store import PictureSourceStore -from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource +from wled_controller.storage.picture_source import ScreenCapturePictureSource, StaticImagePictureSource, VideoCaptureSource from wled_controller.utils import get_logger logger = get_logger(__name__) @@ -63,6 +63,14 @@ def _stream_to_response(s) -> PictureSourceResponse: updated_at=s.updated_at, description=s.description, tags=getattr(s, 'tags', []), + # Video fields + url=getattr(s, "url", None), + loop=getattr(s, "loop", None), + playback_speed=getattr(s, "playback_speed", None), + start_time=getattr(s, "start_time", None), + end_time=getattr(s, "end_time", None), + resolution_limit=getattr(s, "resolution_limit", None), + clock_id=getattr(s, "clock_id", None), ) @@ -207,6 +215,14 @@ async def create_picture_source( image_source=data.image_source, description=data.description, tags=data.tags, + # Video fields + url=data.url, + loop=data.loop, + playback_speed=data.playback_speed, + start_time=data.start_time, + end_time=data.end_time, + resolution_limit=data.resolution_limit, + clock_id=data.clock_id, ) fire_entity_event("picture_source", "created", stream.id) return _stream_to_response(stream) @@ -253,6 +269,14 @@ async def update_picture_source( image_source=data.image_source, description=data.description, tags=data.tags, + # Video fields + url=data.url, + loop=data.loop, + playback_speed=data.playback_speed, + start_time=data.start_time, + end_time=data.end_time, + resolution_limit=data.resolution_limit, + clock_id=data.clock_id, ) fire_entity_event("picture_source", "updated", stream_id) return _stream_to_response(stream) @@ -292,6 +316,52 @@ async def delete_picture_source( raise HTTPException(status_code=500, detail=str(e)) +@router.get("/api/v1/picture-sources/{stream_id}/thumbnail", tags=["Picture Sources"]) +async def get_video_thumbnail( + stream_id: str, + _auth: AuthRequired, + store: PictureSourceStore = Depends(get_picture_source_store), +): + """Get a thumbnail for a video picture source (first frame).""" + import base64 + from io import BytesIO + + from PIL import Image + + from wled_controller.core.processing.video_stream import extract_thumbnail + from wled_controller.storage.picture_source import VideoCaptureSource + + try: + source = store.get_stream(stream_id) + if not isinstance(source, VideoCaptureSource): + raise HTTPException(status_code=400, detail="Not a video source") + + frame = await asyncio.get_event_loop().run_in_executor( + None, extract_thumbnail, source.url, source.resolution_limit + ) + if frame is None: + raise HTTPException(status_code=404, detail="Could not extract thumbnail") + + # Encode as JPEG + pil_img = Image.fromarray(frame) + # Resize to max 320px wide for thumbnail + if pil_img.width > 320: + ratio = 320 / pil_img.width + pil_img = pil_img.resize((320, int(pil_img.height * ratio)), Image.LANCZOS) + + buf = BytesIO() + pil_img.save(buf, format="JPEG", quality=80) + b64 = base64.b64encode(buf.getvalue()).decode() + + return {"thumbnail": f"data:image/jpeg;base64,{b64}", "width": pil_img.width, "height": pil_img.height} + + except HTTPException: + raise + except Exception as e: + logger.error(f"Failed to extract video thumbnail: {e}") + raise HTTPException(status_code=500, detail=str(e)) + + @router.post("/api/v1/picture-sources/{stream_id}/test", response_model=TemplateTestResponse, tags=["Picture Sources"]) async def test_picture_source( stream_id: str, @@ -530,6 +600,86 @@ async def test_picture_source_ws( await websocket.close(code=4003, reason="Static image streams don't support live test") return + # Video sources: use VideoCaptureLiveStream for test preview + if isinstance(raw_stream, VideoCaptureSource): + from wled_controller.core.processing.video_stream import VideoCaptureLiveStream + + await websocket.accept() + logger.info(f"Video source test WS connected for {stream_id} ({duration}s)") + + video_stream = VideoCaptureLiveStream( + url=raw_stream.url, + loop=raw_stream.loop, + playback_speed=raw_stream.playback_speed, + start_time=raw_stream.start_time, + end_time=raw_stream.end_time, + resolution_limit=raw_stream.resolution_limit, + target_fps=raw_stream.target_fps, + ) + + def _encode_video_frame(image, pw): + """Encode numpy RGB image as JPEG base64 data URI.""" + from PIL import Image as PILImage + pil = PILImage.fromarray(image) + if pw and pil.width > pw: + ratio = pw / pil.width + pil = pil.resize((pw, int(pil.height * ratio)), PILImage.LANCZOS) + buf = io.BytesIO() + pil.save(buf, format="JPEG", quality=80) + b64 = base64.b64encode(buf.getvalue()).decode() + return f"data:image/jpeg;base64,{b64}", pil.width, pil.height + + try: + await asyncio.get_event_loop().run_in_executor(None, video_stream.start) + import time as _time + fps = min(raw_stream.target_fps or 30, 30) + frame_time = 1.0 / fps + end_at = _time.monotonic() + duration + frame_count = 0 + last_frame = None + while _time.monotonic() < end_at: + frame = video_stream.get_latest_frame() + if frame is not None and frame.image is not None and frame is not last_frame: + last_frame = frame + frame_count += 1 + thumb, w, h = await asyncio.get_event_loop().run_in_executor( + None, _encode_video_frame, frame.image, preview_width or None, + ) + elapsed = duration - (end_at - _time.monotonic()) + await websocket.send_json({ + "type": "frame", + "thumbnail": thumb, + "width": w, "height": h, + "elapsed": round(elapsed, 1), + "frame_count": frame_count, + }) + await asyncio.sleep(frame_time) + # Send final result + if last_frame is not None: + full_img, fw, fh = await asyncio.get_event_loop().run_in_executor( + None, _encode_video_frame, last_frame.image, None, + ) + await websocket.send_json({ + "type": "result", + "full_image": full_img, + "width": fw, "height": fh, + "total_frames": frame_count, + "duration": duration, + "avg_fps": round(frame_count / max(duration, 0.001), 1), + }) + except WebSocketDisconnect: + pass + except Exception as e: + logger.error(f"Video source test WS error for {stream_id}: {e}") + try: + await websocket.send_json({"type": "error", "detail": str(e)}) + except Exception: + pass + finally: + video_stream.stop() + logger.info(f"Video source test WS disconnected for {stream_id}") + return + if not isinstance(raw_stream, ScreenCapturePictureSource): await websocket.close(code=4003, reason="Unsupported stream type for live test") return diff --git a/server/src/wled_controller/api/schemas/picture_sources.py b/server/src/wled_controller/api/schemas/picture_sources.py index b1e725a..7afbfec 100644 --- a/server/src/wled_controller/api/schemas/picture_sources.py +++ b/server/src/wled_controller/api/schemas/picture_sources.py @@ -10,15 +10,23 @@ class PictureSourceCreate(BaseModel): """Request to create a picture source.""" name: str = Field(description="Stream name", min_length=1, max_length=100) - stream_type: Literal["raw", "processed", "static_image"] = Field(description="Stream type") + stream_type: Literal["raw", "processed", "static_image", "video"] = 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=1, le=90) + target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, 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) tags: List[str] = Field(default_factory=list, description="User-defined tags") + # Video fields + url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL") + loop: bool = Field(True, description="Loop video playback") + playback_speed: float = Field(1.0, description="Playback speed multiplier", ge=0.1, le=10.0) + start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0) + end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0) + resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680) + clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing") class PictureSourceUpdate(BaseModel): @@ -27,12 +35,20 @@ class PictureSourceUpdate(BaseModel): name: Optional[str] = Field(None, description="Stream name", min_length=1, max_length=100) 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=1, le=90) + target_fps: Optional[int] = Field(None, description="Target FPS", ge=1, 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) tags: Optional[List[str]] = None + # Video fields + url: Optional[str] = Field(None, description="Video URL, file path, or YouTube URL") + loop: Optional[bool] = Field(None, description="Loop video playback") + playback_speed: Optional[float] = Field(None, description="Playback speed multiplier", ge=0.1, le=10.0) + start_time: Optional[float] = Field(None, description="Trim start time in seconds", ge=0) + end_time: Optional[float] = Field(None, description="Trim end time in seconds", ge=0) + resolution_limit: Optional[int] = Field(None, description="Max width in pixels for decode downscale", ge=64, le=7680) + clock_id: Optional[str] = Field(None, description="Sync clock ID for frame-accurate timing") class PictureSourceResponse(BaseModel): @@ -40,7 +56,7 @@ class PictureSourceResponse(BaseModel): id: str = Field(description="Stream ID") name: str = Field(description="Stream name") - stream_type: str = Field(description="Stream type (raw, processed, or static_image)") + stream_type: str = Field(description="Stream type (raw, processed, static_image, or video)") 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") @@ -51,6 +67,14 @@ class PictureSourceResponse(BaseModel): created_at: datetime = Field(description="Creation timestamp") updated_at: datetime = Field(description="Last update timestamp") description: Optional[str] = Field(None, description="Stream description") + # Video fields + url: Optional[str] = Field(None, description="Video URL") + loop: Optional[bool] = Field(None, description="Loop video playback") + playback_speed: Optional[float] = Field(None, description="Playback speed multiplier") + start_time: Optional[float] = Field(None, description="Trim start time in seconds") + end_time: Optional[float] = Field(None, description="Trim end time in seconds") + resolution_limit: Optional[int] = Field(None, description="Max width for decode") + clock_id: Optional[str] = Field(None, description="Sync clock ID") class PictureSourceListResponse(BaseModel): diff --git a/server/src/wled_controller/core/processing/live_stream_manager.py b/server/src/wled_controller/core/processing/live_stream_manager.py index 049dd99..cae9254 100644 --- a/server/src/wled_controller/core/processing/live_stream_manager.py +++ b/server/src/wled_controller/core/processing/live_stream_manager.py @@ -22,6 +22,7 @@ from wled_controller.core.processing.live_stream import ( ScreenCaptureLiveStream, StaticImageLiveStream, ) +from wled_controller.core.processing.video_stream import VideoCaptureLiveStream from wled_controller.utils import get_logger logger = get_logger(__name__) @@ -178,6 +179,7 @@ class LiveStreamManager: ProcessedPictureSource, ScreenCapturePictureSource, StaticImagePictureSource, + VideoCaptureSource, ) stream_config = self._picture_source_store.get_stream(picture_source_id) @@ -191,6 +193,9 @@ class LiveStreamManager: elif isinstance(stream_config, StaticImagePictureSource): return self._create_static_image_live_stream(stream_config), None + elif isinstance(stream_config, VideoCaptureSource): + return self._create_video_live_stream(stream_config), None + else: raise ValueError(f"Unknown picture source type: {type(stream_config)}") @@ -259,6 +264,31 @@ class LiveStreamManager: logger.warning(f"Skipping unknown filter '{fi.filter_id}': {e}") return resolved + def _create_video_live_stream(self, config) -> VideoCaptureLiveStream: + """Create a VideoCaptureLiveStream from a VideoCaptureSource config.""" + stream = VideoCaptureLiveStream( + url=config.url, + loop=config.loop, + playback_speed=config.playback_speed, + start_time=config.start_time, + end_time=config.end_time, + resolution_limit=config.resolution_limit, + target_fps=config.target_fps, + ) + + # Attach sync clock if configured + if config.clock_id: + try: + from wled_controller.core.processing.processor_manager import ProcessorManager + manager = ProcessorManager.instance() + if manager and hasattr(manager, '_sync_clock_manager'): + clock = manager._sync_clock_manager.acquire(config.clock_id) + stream.set_clock(clock) + except Exception as e: + logger.warning(f"Could not attach clock {config.clock_id} to video stream: {e}") + + return stream + def _create_static_image_live_stream(self, config) -> StaticImageLiveStream: """Create a StaticImageLiveStream from a StaticImagePictureSource config.""" image = self._load_static_image(config.image_source) diff --git a/server/src/wled_controller/core/processing/video_stream.py b/server/src/wled_controller/core/processing/video_stream.py new file mode 100644 index 0000000..d73b6c7 --- /dev/null +++ b/server/src/wled_controller/core/processing/video_stream.py @@ -0,0 +1,356 @@ +"""Video file/URL live stream — decodes video frames via OpenCV. + +Supports local files, HTTP URLs, RTSP streams, and YouTube URLs (via yt-dlp). +Optional sync clock integration for frame-accurate seeking. +""" + +import re +import threading +import time +from typing import Optional + +import cv2 +import numpy as np + +from wled_controller.core.capture_engines.base import ScreenCapture +from wled_controller.core.processing.live_stream import LiveStream +from wled_controller.utils import get_logger + +logger = get_logger(__name__) + +# YouTube URL patterns +_YT_PATTERNS = [ + re.compile(r"(?:https?://)?(?:www\.)?youtube\.com/watch\?v=([a-zA-Z0-9_-]{11})"), + re.compile(r"(?:https?://)?youtu\.be/([a-zA-Z0-9_-]{11})"), + re.compile(r"(?:https?://)?(?:www\.)?youtube\.com/shorts/([a-zA-Z0-9_-]{11})"), + re.compile(r"youtube://([a-zA-Z0-9_-]{11})"), +] + + +def is_youtube_url(url: str) -> bool: + return any(p.search(url) for p in _YT_PATTERNS) + + +def resolve_youtube_url(url: str, resolution_limit: Optional[int] = None) -> str: + """Resolve a YouTube URL to a direct stream URL using yt-dlp.""" + try: + import yt_dlp + except ImportError: + raise RuntimeError("yt-dlp is required for YouTube support: pip install yt-dlp") + + max_h = resolution_limit or 720 + format_spec = f"bestvideo[height<={max_h}][ext=mp4]/best[height<={max_h}][ext=mp4]/best[height<={max_h}]/best" + + ydl_opts = { + "format": format_spec, + "quiet": True, + "no_warnings": True, + "extract_flat": False, + } + + with yt_dlp.YoutubeDL(ydl_opts) as ydl: + info = ydl.extract_info(url, download=False) + stream_url = info.get("url") + if not stream_url: + formats = info.get("requested_formats") or [] + for f in formats: + if f.get("vcodec") != "none": + stream_url = f["url"] + break + if not stream_url: + raise RuntimeError(f"Could not extract video stream URL from: {url}") + + logger.info( + f"Resolved YouTube URL: {info.get('title', '?')} " + f"({info.get('width', '?')}x{info.get('height', '?')})" + ) + return stream_url + + +def extract_thumbnail(url: str, resolution_limit: Optional[int] = None) -> Optional[np.ndarray]: + """Extract the first frame of a video as a thumbnail (RGB numpy array). + + For YouTube URLs, resolves via yt-dlp first. + Returns None on failure. + """ + try: + actual_url = url + if is_youtube_url(url): + actual_url = resolve_youtube_url(url, resolution_limit) + + cap = cv2.VideoCapture(actual_url) + if not cap.isOpened(): + return None + + ret, frame = cap.read() + cap.release() + + if not ret or frame is None: + return None + + # Convert BGR → RGB + frame = cv2.cvtColor(frame, cv2.COLOR_BGR2RGB) + + # Downscale if needed + if resolution_limit and frame.shape[1] > resolution_limit: + scale = resolution_limit / frame.shape[1] + new_w = resolution_limit + new_h = int(frame.shape[0] * scale) + frame = cv2.resize(frame, (new_w, new_h), interpolation=cv2.INTER_AREA) + + return frame + except Exception as e: + logger.warning(f"Failed to extract thumbnail from {url}: {e}") + return None + + +class VideoCaptureLiveStream(LiveStream): + """Live stream that decodes video frames from a file, URL, or YouTube link. + + A background thread decodes frames at the video's native FPS (or target FPS). + Supports loop, trim (start_time/end_time), playback speed, resolution limit, + and optional sync clock for frame-accurate seeking. + + When a sync clock is attached: + - clock.get_time() determines the current playback position + - clock.speed overrides playback_speed + - clock pause/resume pauses/resumes playback + """ + + def __init__( + self, + url: str, + loop: bool = True, + playback_speed: float = 1.0, + start_time: Optional[float] = None, + end_time: Optional[float] = None, + resolution_limit: Optional[int] = None, + target_fps: int = 30, + ): + self._original_url = url + self._resolved_url: Optional[str] = None + self._loop = loop + self._playback_speed = playback_speed + self._start_time = start_time or 0.0 + self._end_time = end_time + self._resolution_limit = resolution_limit + self._target_fps = target_fps + + self._cap: Optional[cv2.VideoCapture] = None + self._video_fps: float = 30.0 + self._total_frames: int = 0 + self._video_duration: float = 0.0 + self._video_width: int = 0 + self._video_height: int = 0 + + self._latest_frame: Optional[ScreenCapture] = None + self._frame_lock = threading.Lock() + self._running = False + self._thread: Optional[threading.Thread] = None + + # Sync clock (set externally) + self._clock = None + + @property + def target_fps(self) -> int: + return self._target_fps + + @property + def display_index(self) -> Optional[int]: + return None # Not a screen capture + + def set_clock(self, clock) -> None: + """Attach a SyncClockRuntime for frame-accurate seek.""" + self._clock = clock + + def start(self) -> None: + if self._running: + return + + # Resolve YouTube URL if needed + actual_url = self._original_url + if is_youtube_url(actual_url): + actual_url = resolve_youtube_url(actual_url, self._resolution_limit) + self._resolved_url = actual_url + + # Open capture + self._cap = cv2.VideoCapture(actual_url) + if not self._cap.isOpened(): + raise RuntimeError(f"Failed to open video: {self._original_url}") + + self._video_fps = self._cap.get(cv2.CAP_PROP_FPS) or 30.0 + self._total_frames = int(self._cap.get(cv2.CAP_PROP_FRAME_COUNT) or 0) + self._video_width = int(self._cap.get(cv2.CAP_PROP_FRAME_WIDTH)) + self._video_height = int(self._cap.get(cv2.CAP_PROP_FRAME_HEIGHT)) + + if self._total_frames > 0 and self._video_fps > 0: + self._video_duration = self._total_frames / self._video_fps + else: + self._video_duration = 0.0 # Live stream or unknown + + # Use video FPS as target if not overridden, capped at 60 + if self._target_fps <= 0: + self._target_fps = min(int(self._video_fps), 60) + + # Seek to start_time if set + if self._start_time > 0: + self._seek_to(self._start_time) + + self._running = True + self._thread = threading.Thread( + target=self._decode_loop, + name="video-capture", + daemon=True, + ) + self._thread.start() + + logger.info( + f"VideoCaptureLiveStream started: {self._original_url} " + f"({self._video_width}x{self._video_height} @ {self._video_fps:.1f}fps, " + f"duration={self._video_duration:.1f}s)" + ) + + def stop(self) -> None: + if not self._running: + return + + self._running = False + if self._thread: + self._thread.join(timeout=5.0) + self._thread = None + + if self._cap: + self._cap.release() + self._cap = None + + self._latest_frame = None + logger.info(f"VideoCaptureLiveStream stopped: {self._original_url}") + + def get_latest_frame(self) -> Optional[ScreenCapture]: + with self._frame_lock: + return self._latest_frame + + def _seek_to(self, time_sec: float) -> None: + """Seek to a specific time in seconds.""" + if self._cap and self._total_frames > 0: + self._cap.set(cv2.CAP_PROP_POS_MSEC, time_sec * 1000.0) + + def _get_effective_end_time(self) -> float: + """Get the effective end time (end_time or video duration).""" + if self._end_time is not None: + return self._end_time + if self._video_duration > 0: + return self._video_duration + return float("inf") + + def _decode_loop(self) -> None: + """Background thread: decode frames, apply speed/clock, handle loop.""" + frame_time = 1.0 / self._target_fps if self._target_fps > 0 else 1.0 / 30 + playback_start = time.perf_counter() + last_seek_time = -1.0 + buf: Optional[np.ndarray] = None + consecutive_errors = 0 + + try: + while self._running: + loop_start = time.perf_counter() + + # Determine current playback position + if self._clock is not None: + clock_time = self._clock.get_time() + current_time = self._start_time + clock_time + else: + wall_elapsed = time.perf_counter() - playback_start + current_time = self._start_time + wall_elapsed * self._playback_speed + + end_time = self._get_effective_end_time() + + # Handle end of range + if current_time >= end_time: + if self._loop: + # Reset + if self._clock is not None: + # Can't control clock, just wrap + current_time = self._start_time + ( + (current_time - self._start_time) + % max(end_time - self._start_time, 0.001) + ) + else: + playback_start = time.perf_counter() + current_time = self._start_time + + self._seek_to(current_time) + last_seek_time = -1.0 + else: + # End — hold last frame + time.sleep(frame_time) + continue + + # Clock-based seeking: seek when clock position changes significantly + if self._clock is not None and self._total_frames > 0: + # Only seek if position jumped more than 2 frames + threshold = 2.0 / self._video_fps + if abs(current_time - last_seek_time) > threshold or last_seek_time < 0: + self._seek_to(current_time) + last_seek_time = current_time + + # Decode next frame + try: + ret, frame = self._cap.read() + if not ret or frame is None: + if self._loop and self._total_frames > 0: + self._seek_to(self._start_time) + if self._clock is None: + playback_start = time.perf_counter() + last_seek_time = -1.0 + continue + else: + time.sleep(frame_time) + continue + + consecutive_errors = 0 + + # BGR → RGB + cv2.cvtColor(frame, cv2.COLOR_BGR2RGB, dst=frame) + + # Downscale if resolution limit set + if self._resolution_limit and frame.shape[1] > self._resolution_limit: + scale = self._resolution_limit / frame.shape[1] + new_w = self._resolution_limit + new_h = int(frame.shape[0] * scale) + frame = cv2.resize( + frame, (new_w, new_h), interpolation=cv2.INTER_AREA + ) + + h, w = frame.shape[:2] + + # Reuse buffer if shape matches + if buf is None or buf.shape != frame.shape: + buf = frame + else: + np.copyto(buf, frame) + + sc = ScreenCapture( + image=buf, width=w, height=h, display_index=-1 + ) + with self._frame_lock: + self._latest_frame = sc + + except Exception as e: + consecutive_errors += 1 + logger.error(f"Video decode error: {e}") + if consecutive_errors > 10: + backoff = min(1.0, 0.1 * (consecutive_errors - 10)) + time.sleep(backoff) + continue + + # Throttle to target FPS + elapsed = time.perf_counter() - loop_start + remaining = frame_time - elapsed + if remaining > 0: + time.sleep(remaining) + + except Exception as e: + logger.error(f"Fatal video decode loop error: {e}", exc_info=True) + finally: + self._running = False diff --git a/server/src/wled_controller/static/js/core/graph-nodes.js b/server/src/wled_controller/static/js/core/graph-nodes.js index ac86c0a..dedd375 100644 --- a/server/src/wled_controller/static/js/core/graph-nodes.js +++ b/server/src/wled_controller/static/js/core/graph-nodes.js @@ -50,7 +50,7 @@ const SUBTYPE_ICONS = { api_input: P.send, notification: P.bellRing, daylight: P.sun, candlelight: P.flame, processed: P.sparkles, }, - picture_source: { raw: P.monitor, processed: P.palette, static_image: P.image }, + picture_source: { raw: P.monitor, processed: P.palette, static_image: P.image, video: P.film }, value_source: { static: P.layoutDashboard, animated: P.refreshCw, audio: P.music, adaptive_time: P.clock, adaptive_scene: P.cloudSun, daylight: P.sun, diff --git a/server/src/wled_controller/static/js/core/icons.js b/server/src/wled_controller/static/js/core/icons.js index 18557bc..dedc935 100644 --- a/server/src/wled_controller/static/js/core/icons.js +++ b/server/src/wled_controller/static/js/core/icons.js @@ -15,7 +15,7 @@ const _svg = (d) => ``; // ── Type-resolution maps (private) ────────────────────────── const _targetTypeIcons = { led: _svg(P.lightbulb), wled: _svg(P.lightbulb), key_colors: _svg(P.palette) }; -const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image) }; +const _pictureSourceTypeIcons = { raw: _svg(P.monitor), processed: _svg(P.palette), static_image: _svg(P.image), video: _svg(P.film) }; const _colorStripTypeIcons = { picture_advanced: _svg(P.monitor), static: _svg(P.palette), color_cycle: _svg(P.refreshCw), gradient: _svg(P.rainbow), diff --git a/server/src/wled_controller/static/js/features/streams.js b/server/src/wled_controller/static/js/features/streams.js index b7fac8b..732b34f 100644 --- a/server/src/wled_controller/static/js/features/streams.js +++ b/server/src/wled_controller/static/js/features/streams.js @@ -77,6 +77,7 @@ const csProcTemplates = new CardSection('proc-templates', { titleKey: 'postproce const csAudioMulti = new CardSection('audio-multi', { titleKey: 'audio_source.group.multichannel', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('multichannel')", keyAttr: 'data-id' }); const csAudioMono = new CardSection('audio-mono', { titleKey: 'audio_source.group.mono', gridClass: 'templates-grid', addCardOnclick: "showAudioSourceModal('mono')", keyAttr: 'data-id' }); const csStaticStreams = new CardSection('static-streams', { titleKey: 'streams.group.static_image', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('static_image')", keyAttr: 'data-stream-id' }); +const csVideoStreams = new CardSection('video-streams', { titleKey: 'streams.group.video', gridClass: 'templates-grid', addCardOnclick: "showAddStreamModal('video')", keyAttr: 'data-stream-id' }); const csAudioTemplates = new CardSection('audio-templates', { titleKey: 'audio_template.title', gridClass: 'templates-grid', addCardOnclick: "showAddAudioTemplateModal()", keyAttr: 'data-audio-template-id' }); const csColorStrips = new CardSection('color-strips', { titleKey: 'targets.section.color_strips', gridClass: 'templates-grid', addCardOnclick: "showCSSEditor()", keyAttr: 'data-css-id' }); const csValueSources = new CardSection('value-sources', { titleKey: 'value_source.group.title', gridClass: 'templates-grid', addCardOnclick: "showValueSourceModal()", keyAttr: 'data-id' }); @@ -1250,6 +1251,7 @@ const _streamSectionMap = { raw: [csRawStreams], raw_templates: [csRawTemplates], static_image: [csStaticStreams], + video: [csVideoStreams], processed: [csProcStreams], proc_templates: [csProcTemplates], css_processing: [csCSPTemplates], @@ -1307,6 +1309,15 @@ function renderPictureSourcesList(streams) { detailsHtml = `