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

@@ -92,6 +92,52 @@
<div id="stream-image-validation-status" class="validation-status" style="display: none;"></div>
</div>
<div id="stream-video-fields" style="display: none;">
<div class="form-group">
<div class="label-row">
<label for="stream-video-url" data-i18n="picture_source.video.url">Video URL:</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="picture_source.video.url.hint">Local file path, HTTP URL, or YouTube URL</small>
<input type="text" id="stream-video-url" data-i18n-placeholder="picture_source.video.url.placeholder" placeholder="https://example.com/video.mp4">
</div>
<div class="form-group settings-toggle-group">
<label data-i18n="picture_source.video.loop">Loop:</label>
<label class="settings-toggle">
<input type="checkbox" id="stream-video-loop" checked>
<span class="settings-toggle-slider"></span>
</label>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label for="stream-video-speed" data-i18n="picture_source.video.speed">Playback Speed: <span id="stream-video-speed-value">1.0</span>×</label>
<input type="range" id="stream-video-speed" min="0.1" max="5" step="0.1" value="1.0" oninput="document.getElementById('stream-video-speed-value').textContent=this.value">
</div>
<div class="form-group" style="flex:1">
<label for="stream-video-fps" data-i18n="streams.target_fps">Target FPS:</label>
<input type="number" id="stream-video-fps" min="1" max="60" step="1" value="30">
</div>
</div>
<div class="form-row">
<div class="form-group" style="flex:1">
<label for="stream-video-start" data-i18n="picture_source.video.start_time">Start Time (s):</label>
<input type="number" id="stream-video-start" min="0" step="0.1">
</div>
<div class="form-group" style="flex:1">
<label for="stream-video-end" data-i18n="picture_source.video.end_time">End Time (s):</label>
<input type="number" id="stream-video-end" min="0" step="0.1">
</div>
</div>
<div class="form-group">
<div class="label-row">
<label for="stream-video-resolution" data-i18n="picture_source.video.resolution_limit">Max Width (px):</label>
<button type="button" class="hint-toggle" onclick="toggleHint(this)" title="?">?</button>
</div>
<small class="input-hint" style="display:none" data-i18n="picture_source.video.resolution_limit.hint">Downscale video at decode time for performance</small>
<input type="number" id="stream-video-resolution" min="64" max="7680" step="1" placeholder="720">
</div>
</div>
<div class="form-group">
<label for="stream-description" data-i18n="streams.description_label">Description (optional):</label>
<input type="text" id="stream-description" data-i18n-placeholder="streams.description_placeholder" placeholder="Describe this source...">