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:
@@ -13,11 +13,12 @@ class PictureSource:
|
||||
- "raw": captures from a display using a capture engine template at a target FPS
|
||||
- "processed": applies postprocessing to another picture source
|
||||
- "static_image": returns a static frame from a URL or local file path
|
||||
- "video": decodes frames from a video file, URL, or YouTube link
|
||||
"""
|
||||
|
||||
id: str
|
||||
name: str
|
||||
stream_type: str # "raw", "processed", or "static_image"
|
||||
stream_type: str # "raw", "processed", "static_image", or "video"
|
||||
created_at: datetime
|
||||
updated_at: datetime
|
||||
description: Optional[str] = None
|
||||
@@ -40,6 +41,14 @@ class PictureSource:
|
||||
"source_stream_id": None,
|
||||
"postprocessing_template_id": None,
|
||||
"image_source": None,
|
||||
# Video fields
|
||||
"url": None,
|
||||
"loop": None,
|
||||
"playback_speed": None,
|
||||
"start_time": None,
|
||||
"end_time": None,
|
||||
"resolution_limit": None,
|
||||
"clock_id": None,
|
||||
}
|
||||
|
||||
@staticmethod
|
||||
@@ -79,6 +88,19 @@ class PictureSource:
|
||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
||||
image_source=data.get("image_source") or "",
|
||||
)
|
||||
elif stream_type == "video":
|
||||
return VideoCaptureSource(
|
||||
id=sid, name=name, stream_type=stream_type,
|
||||
created_at=created_at, updated_at=updated_at, description=description, tags=tags,
|
||||
url=data.get("url") or "",
|
||||
loop=data.get("loop", True),
|
||||
playback_speed=data.get("playback_speed", 1.0),
|
||||
start_time=data.get("start_time"),
|
||||
end_time=data.get("end_time"),
|
||||
resolution_limit=data.get("resolution_limit"),
|
||||
clock_id=data.get("clock_id"),
|
||||
target_fps=data.get("target_fps") or 30,
|
||||
)
|
||||
else:
|
||||
return ScreenCapturePictureSource(
|
||||
id=sid, name=name, stream_type=stream_type,
|
||||
@@ -129,3 +151,29 @@ class StaticImagePictureSource(PictureSource):
|
||||
d = super().to_dict()
|
||||
d["image_source"] = self.image_source
|
||||
return d
|
||||
|
||||
|
||||
@dataclass
|
||||
class VideoCaptureSource(PictureSource):
|
||||
"""A video stream from a file, HTTP URL, or YouTube link."""
|
||||
|
||||
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
|
||||
clock_id: Optional[str] = None
|
||||
target_fps: int = 30
|
||||
|
||||
def to_dict(self) -> dict:
|
||||
d = super().to_dict()
|
||||
d["url"] = self.url
|
||||
d["loop"] = self.loop
|
||||
d["playback_speed"] = self.playback_speed
|
||||
d["start_time"] = self.start_time
|
||||
d["end_time"] = self.end_time
|
||||
d["resolution_limit"] = self.resolution_limit
|
||||
d["clock_id"] = self.clock_id
|
||||
d["target_fps"] = self.target_fps
|
||||
return d
|
||||
|
||||
@@ -11,6 +11,7 @@ from wled_controller.storage.picture_source import (
|
||||
ProcessedPictureSource,
|
||||
ScreenCapturePictureSource,
|
||||
StaticImagePictureSource,
|
||||
VideoCaptureSource,
|
||||
)
|
||||
from wled_controller.utils import get_logger
|
||||
|
||||
@@ -83,24 +84,21 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
|
||||
image_source: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
# Video fields
|
||||
url: Optional[str] = None,
|
||||
loop: bool = True,
|
||||
playback_speed: float = 1.0,
|
||||
start_time: Optional[float] = None,
|
||||
end_time: Optional[float] = None,
|
||||
resolution_limit: Optional[int] = None,
|
||||
clock_id: Optional[str] = None,
|
||||
) -> PictureSource:
|
||||
"""Create a new picture source.
|
||||
|
||||
Args:
|
||||
name: Stream name
|
||||
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", "static_image"):
|
||||
if stream_type not in ("raw", "processed", "static_image", "video"):
|
||||
raise ValueError(f"Invalid stream type: {stream_type}")
|
||||
|
||||
if stream_type == "raw":
|
||||
@@ -124,6 +122,9 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
|
||||
elif stream_type == "static_image":
|
||||
if not image_source:
|
||||
raise ValueError("Static image streams require image_source")
|
||||
elif stream_type == "video":
|
||||
if not url:
|
||||
raise ValueError("Video streams require url")
|
||||
|
||||
# Check for duplicate name
|
||||
self._check_name_unique(name)
|
||||
@@ -151,6 +152,18 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
|
||||
source_stream_id=source_stream_id, # type: ignore[arg-type]
|
||||
postprocessing_template_id=postprocessing_template_id, # type: ignore[arg-type]
|
||||
)
|
||||
elif stream_type == "video":
|
||||
stream = VideoCaptureSource(
|
||||
**common,
|
||||
url=url, # type: ignore[arg-type]
|
||||
loop=loop,
|
||||
playback_speed=playback_speed,
|
||||
start_time=start_time,
|
||||
end_time=end_time,
|
||||
resolution_limit=resolution_limit,
|
||||
clock_id=clock_id,
|
||||
target_fps=target_fps or 30,
|
||||
)
|
||||
else:
|
||||
stream = StaticImagePictureSource(
|
||||
**common,
|
||||
@@ -175,6 +188,14 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
|
||||
image_source: Optional[str] = None,
|
||||
description: Optional[str] = None,
|
||||
tags: Optional[List[str]] = None,
|
||||
# Video fields
|
||||
url: Optional[str] = None,
|
||||
loop: Optional[bool] = None,
|
||||
playback_speed: Optional[float] = None,
|
||||
start_time: Optional[float] = None,
|
||||
end_time: Optional[float] = None,
|
||||
resolution_limit: Optional[int] = None,
|
||||
clock_id: Optional[str] = None,
|
||||
) -> PictureSource:
|
||||
"""Update an existing picture source.
|
||||
|
||||
@@ -214,6 +235,23 @@ class PictureSourceStore(BaseJsonStore[PictureSource]):
|
||||
elif isinstance(stream, StaticImagePictureSource):
|
||||
if image_source is not None:
|
||||
stream.image_source = image_source
|
||||
elif isinstance(stream, VideoCaptureSource):
|
||||
if url is not None:
|
||||
stream.url = url
|
||||
if loop is not None:
|
||||
stream.loop = loop
|
||||
if playback_speed is not None:
|
||||
stream.playback_speed = playback_speed
|
||||
if start_time is not None:
|
||||
stream.start_time = start_time if start_time > 0 else None
|
||||
if end_time is not None:
|
||||
stream.end_time = end_time if end_time > 0 else None
|
||||
if resolution_limit is not None:
|
||||
stream.resolution_limit = resolution_limit if resolution_limit > 0 else None
|
||||
if clock_id is not None:
|
||||
stream.clock_id = resolve_ref(clock_id, stream.clock_id)
|
||||
if target_fps is not None:
|
||||
stream.target_fps = target_fps
|
||||
|
||||
stream.updated_at = datetime.now(timezone.utc)
|
||||
|
||||
|
||||
Reference in New Issue
Block a user