Add video picture source: file, URL, YouTube, sync clock, trim, test preview
Backend:
- VideoCaptureSource dataclass with url, loop, playback_speed, start/end_time,
resolution_limit, clock_id, target_fps fields
- VideoCaptureStream: OpenCV decode thread with frame-accurate sync clock seeking,
loop, trim range, resolution downscale at decode time
- YouTube URL resolution via yt-dlp (auto-detects youtube.com, youtu.be, shorts)
- Thumbnail extraction from first frame (GET /picture-sources/{id}/thumbnail)
- Video test WS preview: streams JPEG frames with elapsed/frame_count metadata
- Run video_stream.start() in executor to avoid blocking event loop during
yt-dlp resolution
- Full CRUD via existing picture source API (stream_type: "video")
- Wired into LiveStreamManager for target streaming
Frontend:
- Video icon (film) in picture source type map and graph node subtypes
- Video tree nav node in Sources tab with CardSection
- Video fields in stream add/edit modal: URL, loop toggle, playback speed slider,
target FPS, start/end trim times, resolution limit
- Video card rendering with URL, FPS, loop, speed badges
- Clone data support for video sources
- i18n keys for video source in en/ru/zh
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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)
|
||||
|
||||
@@ -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
|
||||
Reference in New Issue
Block a user