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:
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user