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

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

View File

@@ -15,7 +15,7 @@ const _svg = (d) => `<svg class="icon" viewBox="0 0 24 24">${d}</svg>`;
// ── 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),

View File

@@ -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 = `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(src)}">${ICON_WEB} ${escapeHtml(src)}</span>
</div>`;
} else if (stream.stream_type === 'video') {
const url = stream.url || '';
const shortUrl = url.length > 40 ? url.slice(0, 37) + '...' : url;
detailsHtml = `<div class="stream-card-props">
<span class="stream-card-prop stream-card-prop-full" title="${escapeHtml(url)}">${ICON_WEB} ${escapeHtml(shortUrl)}</span>
<span class="stream-card-prop" title="${t('streams.target_fps')}">${ICON_FPS} ${stream.target_fps ?? 30}</span>
${stream.loop !== false ? `<span class="stream-card-prop">↻</span>` : ''}
${stream.playback_speed && stream.playback_speed !== 1.0 ? `<span class="stream-card-prop">${stream.playback_speed}×</span>` : ''}
</div>`;
}
return wrapCard({
@@ -1427,6 +1438,7 @@ function renderPictureSourcesList(streams) {
const rawStreams = streams.filter(s => s.stream_type === 'raw');
const processedStreams = streams.filter(s => s.stream_type === 'processed');
const staticImageStreams = streams.filter(s => s.stream_type === 'static_image');
const videoStreams = streams.filter(s => s.stream_type === 'video');
const multichannelSources = _cachedAudioSources.filter(s => s.source_type === 'multichannel');
const monoSources = _cachedAudioSources.filter(s => s.source_type === 'mono');
@@ -1445,6 +1457,7 @@ function renderPictureSourcesList(streams) {
{ key: 'raw', icon: getPictureSourceIcon('raw'), titleKey: 'streams.group.raw', count: rawStreams.length },
{ key: 'raw_templates', icon: ICON_CAPTURE_TEMPLATE, titleKey: 'streams.group.raw_templates', count: _cachedCaptureTemplates.length },
{ key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image', count: staticImageStreams.length },
{ key: 'video', icon: getPictureSourceIcon('video'), titleKey: 'streams.group.video', count: videoStreams.length },
{ key: 'processed', icon: getPictureSourceIcon('processed'), titleKey: 'streams.group.processed', count: processedStreams.length },
{ key: 'proc_templates', icon: ICON_PP_TEMPLATE, titleKey: 'streams.group.proc_templates', count: _cachedPPTemplates.length },
{ key: 'css_processing', icon: ICON_CSPT, titleKey: 'streams.group.css_processing', count: csptTemplates.length },
@@ -1467,6 +1480,10 @@ function renderPictureSourcesList(streams) {
key: 'static_image', icon: getPictureSourceIcon('static_image'), titleKey: 'streams.group.static_image',
count: staticImageStreams.length,
},
{
key: 'video', icon: getPictureSourceIcon('video'), titleKey: 'streams.group.video',
count: videoStreams.length,
},
{
key: 'processing_group', icon: getPictureSourceIcon('processed'), titleKey: 'tree.group.processing',
children: [
@@ -1590,6 +1607,7 @@ function renderPictureSourcesList(streams) {
const monoItems = csAudioMono.applySortOrder(monoSources.map(s => ({ key: s.id, html: renderAudioSourceCard(s) })));
const audioTemplateItems = csAudioTemplates.applySortOrder(_cachedAudioTemplates.map(t => ({ key: t.id, html: renderAudioTemplateCard(t) })));
const staticItems = csStaticStreams.applySortOrder(staticImageStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const videoItems = csVideoStreams.applySortOrder(videoStreams.map(s => ({ key: s.id, html: renderStreamCard(s) })));
const colorStripItems = csColorStrips.applySortOrder(colorStrips.map(s => ({ key: s.id, html: createColorStripCard(s, pictureSourceMap, audioSourceMap) })));
const valueItems = csValueSources.applySortOrder(_cachedValueSources.map(s => ({ key: s.id, html: createValueSourceCard(s) })));
const syncClockItems = csSyncClocks.applySortOrder(_cachedSyncClocks.map(s => ({ key: s.id, html: createSyncClockCard(s) })));
@@ -1601,6 +1619,7 @@ function renderPictureSourcesList(streams) {
raw: rawStreams.length,
raw_templates: _cachedCaptureTemplates.length,
static_image: staticImageStreams.length,
video: videoStreams.length,
processed: processedStreams.length,
proc_templates: _cachedPPTemplates.length,
css_processing: csptTemplates.length,
@@ -1619,6 +1638,7 @@ function renderPictureSourcesList(streams) {
csAudioMono.reconcile(monoItems);
csAudioTemplates.reconcile(audioTemplateItems);
csStaticStreams.reconcile(staticItems);
csVideoStreams.reconcile(videoItems);
csValueSources.reconcile(valueItems);
csSyncClocks.reconcile(syncClockItems);
} else {
@@ -1634,12 +1654,13 @@ function renderPictureSourcesList(streams) {
else if (tab.key === 'audio') panelContent = csAudioMulti.render(multiItems) + csAudioMono.render(monoItems) + csAudioTemplates.render(audioTemplateItems);
else if (tab.key === 'value') panelContent = csValueSources.render(valueItems);
else if (tab.key === 'sync') panelContent = csSyncClocks.render(syncClockItems);
else if (tab.key === 'video') panelContent = csVideoStreams.render(videoItems);
else panelContent = csStaticStreams.render(staticItems);
return `<div class="stream-tab-panel${tab.key === activeTab ? ' active' : ''}" id="stream-tab-${tab.key}">${panelContent}</div>`;
}).join('');
container.innerHTML = panels;
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csValueSources, csSyncClocks]);
CardSection.bindAll([csRawStreams, csRawTemplates, csProcStreams, csProcTemplates, csCSPTemplates, csColorStrips, csAudioMulti, csAudioMono, csAudioTemplates, csStaticStreams, csVideoStreams, csValueSources, csSyncClocks]);
// Render tree sidebar with expand/collapse buttons
_streamsTree.setExtraHtml(`<button class="btn-expand-collapse" onclick="expandAllStreamSections()" data-i18n-title="section.expand_all" title="${t('section.expand_all')}">⊞</button><button class="btn-expand-collapse" onclick="collapseAllStreamSections()" data-i18n-title="section.collapse_all" title="${t('section.collapse_all')}">⊟</button><button class="tutorial-trigger-btn" onclick="startSourcesTutorial()" data-i18n-title="tour.restart" title="${t('tour.restart')}">${ICON_HELP}</button>`);
@@ -1647,6 +1668,7 @@ function renderPictureSourcesList(streams) {
_streamsTree.observeSections('streams-list', {
'raw-streams': 'raw', 'raw-templates': 'raw_templates',
'static-streams': 'static_image',
'video-streams': 'video',
'proc-streams': 'processed', 'proc-templates': 'proc_templates',
'css-proc-templates': 'css_processing',
'color-strips': 'color_strip',
@@ -1662,6 +1684,7 @@ export function onStreamTypeChange() {
document.getElementById('stream-raw-fields').style.display = streamType === 'raw' ? '' : 'none';
document.getElementById('stream-processed-fields').style.display = streamType === 'processed' ? '' : 'none';
document.getElementById('stream-static-image-fields').style.display = streamType === 'static_image' ? '' : 'none';
document.getElementById('stream-video-fields').style.display = streamType === 'video' ? '' : 'none';
}
export function onStreamDisplaySelected(displayIndex, display) {
@@ -1705,7 +1728,7 @@ function _autoGenerateStreamName() {
export async function showAddStreamModal(presetType, cloneData = null) {
const streamType = (cloneData && cloneData.stream_type) || presetType || 'raw';
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image' };
const titleKeys = { raw: 'streams.add.raw', processed: 'streams.add.processed', static_image: 'streams.add.static_image', video: 'streams.add.video' };
document.getElementById('stream-modal-title').innerHTML = `${getPictureSourceIcon(streamType)} ${t(titleKeys[streamType] || 'streams.add')}`;
document.getElementById('stream-form').reset();
document.getElementById('stream-id').value = '';
@@ -1754,6 +1777,16 @@ export async function showAddStreamModal(presetType, cloneData = null) {
} else if (streamType === 'static_image') {
document.getElementById('stream-image-source').value = cloneData.image_source || '';
if (cloneData.image_source) validateStaticImage();
} else if (streamType === 'video') {
document.getElementById('stream-video-url').value = cloneData.url || '';
document.getElementById('stream-video-loop').checked = cloneData.loop !== false;
document.getElementById('stream-video-speed').value = cloneData.playback_speed || 1.0;
const cloneSpeedLabel = document.getElementById('stream-video-speed-value');
if (cloneSpeedLabel) cloneSpeedLabel.textContent = cloneData.playback_speed || 1.0;
document.getElementById('stream-video-fps').value = cloneData.target_fps || 30;
document.getElementById('stream-video-start').value = cloneData.start_time || '';
document.getElementById('stream-video-end').value = cloneData.end_time || '';
document.getElementById('stream-video-resolution').value = cloneData.resolution_limit || '';
}
}
@@ -1780,7 +1813,7 @@ export async function editStream(streamId) {
if (!response.ok) throw new Error(`Failed to load stream: ${response.status}`);
const stream = await response.json();
const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image' };
const editTitleKeys = { raw: 'streams.edit.raw', processed: 'streams.edit.processed', static_image: 'streams.edit.static_image', video: 'streams.edit.video' };
document.getElementById('stream-modal-title').innerHTML = `${getPictureSourceIcon(stream.stream_type)} ${t(editTitleKeys[stream.stream_type] || 'streams.edit')}`;
document.getElementById('stream-id').value = streamId;
document.getElementById('stream-name').value = stream.name;
@@ -1814,6 +1847,16 @@ export async function editStream(streamId) {
} else if (stream.stream_type === 'static_image') {
document.getElementById('stream-image-source').value = stream.image_source || '';
if (stream.image_source) validateStaticImage();
} else if (stream.stream_type === 'video') {
document.getElementById('stream-video-url').value = stream.url || '';
document.getElementById('stream-video-loop').checked = stream.loop !== false;
document.getElementById('stream-video-speed').value = stream.playback_speed || 1.0;
const speedLabel = document.getElementById('stream-video-speed-value');
if (speedLabel) speedLabel.textContent = stream.playback_speed || 1.0;
document.getElementById('stream-video-fps').value = stream.target_fps || 30;
document.getElementById('stream-video-start').value = stream.start_time || '';
document.getElementById('stream-video-end').value = stream.end_time || '';
document.getElementById('stream-video-resolution').value = stream.resolution_limit || '';
}
_showStreamModalLoading(false);
@@ -1993,6 +2036,19 @@ export async function saveStream() {
const imageSource = document.getElementById('stream-image-source').value.trim();
if (!imageSource) { showToast(t('streams.error.required'), 'error'); return; }
payload.image_source = imageSource;
} else if (streamType === 'video') {
const url = document.getElementById('stream-video-url').value.trim();
if (!url) { showToast(t('streams.error.required'), 'error'); return; }
payload.url = url;
payload.loop = document.getElementById('stream-video-loop').checked;
payload.playback_speed = parseFloat(document.getElementById('stream-video-speed').value) || 1.0;
payload.target_fps = parseInt(document.getElementById('stream-video-fps').value) || 30;
const startTime = parseFloat(document.getElementById('stream-video-start').value);
if (!isNaN(startTime) && startTime > 0) payload.start_time = startTime;
const endTime = parseFloat(document.getElementById('stream-video-end').value);
if (!isNaN(endTime) && endTime > 0) payload.end_time = endTime;
const resLimit = parseInt(document.getElementById('stream-video-resolution').value);
if (!isNaN(resLimit) && resLimit > 0) payload.resolution_limit = resLimit;
}
try {

View File

@@ -553,6 +553,20 @@
"streams.add.static_image": "Add Static Image Source",
"streams.edit.static_image": "Edit Static Image Source",
"streams.type.static_image": "Static Image",
"streams.group.video": "Video",
"streams.add.video": "Add Video Source",
"streams.edit.video": "Edit Video Source",
"picture_source.type.video": "Video",
"picture_source.type.video.desc": "Stream frames from video file, URL, or YouTube",
"picture_source.video.url": "Video URL:",
"picture_source.video.url.hint": "Local file path, HTTP URL, or YouTube URL",
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
"picture_source.video.loop": "Loop:",
"picture_source.video.speed": "Playback Speed:",
"picture_source.video.start_time": "Start Time (s):",
"picture_source.video.end_time": "End Time (s):",
"picture_source.video.resolution_limit": "Max Width (px):",
"picture_source.video.resolution_limit.hint": "Downscale video at decode time for performance",
"streams.image_source": "Image Source:",
"streams.image_source.placeholder": "https://example.com/image.jpg or C:\\path\\to\\image.png",
"streams.image_source.hint": "Enter a URL (http/https) or local file path to an image",

View File

@@ -553,6 +553,20 @@
"streams.add.static_image": "Добавить статическое изображение (источник)",
"streams.edit.static_image": "Редактировать статическое изображение (источник)",
"streams.type.static_image": "Статическое изображение",
"streams.group.video": "Видео",
"streams.add.video": "Добавить видеоисточник",
"streams.edit.video": "Редактировать видеоисточник",
"picture_source.type.video": "Видео",
"picture_source.type.video.desc": "Потоковые кадры из видеофайла, URL или YouTube",
"picture_source.video.url": "URL видео:",
"picture_source.video.url.hint": "Локальный файл, HTTP URL или YouTube URL",
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
"picture_source.video.loop": "Зацикливание:",
"picture_source.video.speed": "Скорость воспроизведения:",
"picture_source.video.start_time": "Время начала (с):",
"picture_source.video.end_time": "Время окончания (с):",
"picture_source.video.resolution_limit": "Макс. ширина (px):",
"picture_source.video.resolution_limit.hint": "Уменьшение видео при декодировании для производительности",
"streams.image_source": "Источник изображения:",
"streams.image_source.placeholder": "https://example.com/image.jpg или C:\\path\\to\\image.png",
"streams.image_source.hint": "Введите URL (http/https) или локальный путь к изображению",

View File

@@ -553,6 +553,20 @@
"streams.add.static_image": "添加静态图片源",
"streams.edit.static_image": "编辑静态图片源",
"streams.type.static_image": "静态图片",
"streams.group.video": "视频",
"streams.add.video": "添加视频源",
"streams.edit.video": "编辑视频源",
"picture_source.type.video": "视频",
"picture_source.type.video.desc": "从视频文件、URL或YouTube流式传输帧",
"picture_source.video.url": "视频URL",
"picture_source.video.url.hint": "本地文件路径、HTTP URL或YouTube URL",
"picture_source.video.url.placeholder": "https://example.com/video.mp4",
"picture_source.video.loop": "循环:",
"picture_source.video.speed": "播放速度:",
"picture_source.video.start_time": "开始时间(秒):",
"picture_source.video.end_time": "结束时间(秒):",
"picture_source.video.resolution_limit": "最大宽度(像素):",
"picture_source.video.resolution_limit.hint": "解码时缩小视频以提高性能",
"streams.image_source": "图片源:",
"streams.image_source.placeholder": "https://example.com/image.jpg 或 C:\\path\\to\\image.png",
"streams.image_source.hint": "输入图片的 URLhttp/https或本地文件路径",