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:
2026-03-15 23:48:43 +03:00
parent 0bbaf81e26
commit 0bb4d7c3aa
14 changed files with 826 additions and 23 deletions

View File

@@ -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

View File

@@ -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)