@@ -2953,13 +2953,17 @@ async function runTemplateTest() {
function buildTestStatsHtml ( result ) {
const p = result . performance ;
const res = ` ${ result . full _capture . width } x ${ result . full _capture . height } ` ;
return `
let html = `
<div class="stat-item"><span> ${ t ( 'templates.test.results.duration' ) } :</span> <strong> ${ p . capture _duration _s . toFixed ( 2 ) } s</strong></div>
<div class="stat-item"><span> ${ t ( 'templates.test.results.frame_count' ) } :</span> <strong> ${ p . frame _count } </strong></div>
<div class="stat-item"><span> ${ t ( 'templates.test.results.frame_count' ) } :</span> <strong> ${ p . frame _count } </strong></div> ` ;
if ( p . frame _count > 1 ) {
html += `
<div class="stat-item"><span> ${ t ( 'templates.test.results.actual_fps' ) } :</span> <strong> ${ p . actual _fps . toFixed ( 1 ) } </strong></div>
<div class="stat-item"><span> ${ t ( 'templates.test.results.avg_capture_time' ) } :</span> <strong> ${ p . avg _capture _time _ms . toFixed ( 1 ) } ms</strong></div>
<div class="stat-item"><span>Resolution:</span> <strong> ${ res } </strong></div>
` ;
<div class="stat-item"><span> ${ t ( 'templates.test.results.avg_capture_time' ) } :</span> <strong> ${ p . avg _capture _time _ms . toFixed ( 1 ) } ms</strong></div> ` ;
}
html += `
<div class="stat-item"><span>Resolution:</span> <strong> ${ res } </strong></div> ` ;
return html ;
}
// Display test results — opens lightbox with stats overlay
@@ -3046,20 +3050,27 @@ async function deleteTemplate(templateId) {
let _cachedStreams = [ ] ;
let _cachedPPTemplates = [ ] ;
let _cachedCaptureTemplates = [ ] ;
let _availableFilters = [ ] ; // Loaded from GET /filters
async function loadPictureStreams ( ) {
try {
// Ensure PP templates are cached so processed stream cards can show filter info
if ( _cachedPPTemplates . length === 0 ) {
// Ensure PP templates and capture templates are cached for stream card display
if ( _cachedPPTemplates . length === 0 || _cachedCaptureTemplates . length === 0 ) {
try {
if ( _availableFilters . length === 0 ) {
const fr = await fetchWithAuth ( '/filters' ) ;
if ( fr . ok ) { const fd = await fr . json ( ) ; _availableFilters = fd . filters || [ ] ; }
}
const pr = await fetchWithAuth ( '/postprocessing-templates' ) ;
if ( pr . ok ) { const pd = await pr . json ( ) ; _cachedPPTemplates = pd . templates || [ ] ; }
} catch ( e ) { console . warn ( 'Could not pre-load PP templates for streams:' , e ) ; }
if ( _cachedPPTemplates . length === 0 ) {
const pr = await fetchWithAuth ( '/postprocessing-templates' ) ;
if ( pr . ok ) { const pd = await pr . json ( ) ; _cachedPPTemplates = pd . templates || [ ] ; }
}
if ( _cachedCaptureTemplates . length === 0 ) {
const cr = await fetchWithAuth ( '/capture-templates' ) ;
if ( cr . ok ) { const cd = await cr . json ( ) ; _cachedCaptureTemplates = cd . templates || [ ] ; }
}
} catch ( e ) { console . warn ( 'Could not pre-load templates for streams:' , e ) ; }
}
const response = await fetchWithAuth ( '/picture-streams' ) ;
if ( ! response . ok ) {
@@ -3094,6 +3105,19 @@ function renderPictureStreamsList(streams) {
</div>
</div>
</div>
<div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">🖼️</span>
<span class="stream-group-title"> ${ t ( 'streams.group.static_image' ) } </span>
<span class="stream-group-count">0</span>
</div>
<div class="templates-grid">
<div class="template-card add-template-card" onclick="showAddStreamModal('static_image')">
<div class="add-template-icon">+</div>
<div class="add-template-label"> ${ t ( 'streams.add.static_image' ) } </div>
</div>
</div>
</div>
<div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">🎨</span>
@@ -3111,13 +3135,24 @@ function renderPictureStreamsList(streams) {
}
const renderCard = ( stream ) => {
const typeIcon = stream . stream _type === 'raw' ? '🖥️' : '🎨' ;
const typeBadge = stream . stream _type === 'raw'
? ` <span class="badge badge-raw"> ${ t ( 'streams.type.raw' ) } </span> `
: ` <span class="badge badge-processed "> ${ t ( 'streams.type.processed ' ) } </span> ` ;
const typeIcons = { raw : '🖥️' , processed : '🎨' , static _image : '🖼️' } ;
const typeIcon = typeIcons [ stream. stream _type ] || '📺' ;
const typeBadges = {
raw : ` <span class="badge badge-raw "> ${ t ( 'streams.type.raw ' ) } </span> ` ,
processed : ` <span class="badge badge-processed"> ${ t ( 'streams.type.processed' ) } </span> ` ,
static _image : ` <span class="badge badge-processed"> ${ t ( 'streams.type.static_image' ) } </span> ` ,
} ;
const typeBadge = typeBadges [ stream . stream _type ] || '' ;
let detailsHtml = '' ;
if ( stream . stream _type === 'raw' ) {
let captureTemplateHtml = '' ;
if ( stream . capture _template _id ) {
const capTmpl = _cachedCaptureTemplates . find ( t => t . id === stream . capture _template _id ) ;
if ( capTmpl ) {
captureTemplateHtml = ` <div class="template-config"><strong> ${ t ( 'streams.capture_template' ) } </strong> ${ escapeHtml ( capTmpl . name ) } </div> ` ;
}
}
detailsHtml = `
<div class="template-config">
<strong> ${ t ( 'streams.display' ) } </strong> ${ stream . display _index ? ? 0 }
@@ -3125,8 +3160,9 @@ function renderPictureStreamsList(streams) {
<div class="template-config">
<strong> ${ t ( 'streams.target_fps' ) } </strong> ${ stream . target _fps ? ? 30 }
</div>
${ captureTemplateHtml }
` ;
} else {
} else if ( stream . stream _type === 'processed' ) {
// Find source stream name and PP template name
const sourceStream = _cachedStreams . find ( s => s . id === stream . source _stream _id ) ;
const sourceName = sourceStream ? escapeHtml ( sourceStream . name ) : ( stream . source _stream _id || '-' ) ;
@@ -3144,6 +3180,15 @@ function renderPictureStreamsList(streams) {
</div>
${ ppTemplateHtml }
` ;
} else if ( stream . stream _type === 'static_image' ) {
const src = stream . image _source || '' ;
const truncated = src . length > 50 ? src . substring ( 0 , 47 ) + '...' : src ;
detailsHtml = `
<div class="template-config">
<strong> ${ t ( 'streams.image_source' ) } </strong>
</div>
<div class="stream-card-image-source" title=" ${ escapeHtml ( src ) } "> ${ escapeHtml ( truncated ) } </div>
` ;
}
return `
@@ -3171,6 +3216,7 @@ function renderPictureStreamsList(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' ) ;
let html = '' ;
@@ -3190,6 +3236,22 @@ function renderPictureStreamsList(streams) {
</div>
</div> ` ;
// Static Image streams section
html += ` <div class="stream-group">
<div class="stream-group-header">
<span class="stream-group-icon">🖼️</span>
<span class="stream-group-title"> ${ t ( 'streams.group.static_image' ) } </span>
<span class="stream-group-count"> ${ staticImageStreams . length } </span>
</div>
<div class="templates-grid">
${ staticImageStreams . map ( renderCard ) . join ( '' ) }
<div class="template-card add-template-card" onclick="showAddStreamModal('static_image')">
<div class="add-template-icon">+</div>
<div class="add-template-label"> ${ t ( 'streams.add.static_image' ) } </div>
</div>
</div>
</div> ` ;
// Processed streams section
html += ` <div class="stream-group">
<div class="stream-group-header">
@@ -3213,18 +3275,28 @@ function onStreamTypeChange() {
const streamType = document . getElementById ( 'stream-type' ) . value ;
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' ;
}
async function showAddStreamModal ( presetType ) {
const streamType = presetType || 'raw' ;
const titleKey = streamType === 'raw' ? 'streams.add.raw' : 'streams.add.processed' ;
document . getElementById ( 'stream-modal-title' ) . textContent = t ( titleKey ) ;
const titleKeys = { raw : 'streams.add.raw' , processed : 'streams.add.processed' , static _image : 'streams.add.static_image' } ;
document . getElementById ( 'stream-modal-title' ) . textContent = t ( titleKeys [ streamType ] || 'streams.add' ) ;
document . getElementById ( 'stream-form' ) . reset ( ) ;
document . getElementById ( 'stream-id' ) . value = '' ;
document . getElementById ( 'stream-display-index' ) . value = '' ;
document . getElementById ( 'stream-display-picker-label' ) . textContent = t ( 'displays.picker.select' ) ;
document . getElementById ( 'stream-error' ) . style . display = 'none' ;
document . getElementById ( 'stream-type' ) . value = streamType ;
// Clear static image preview and wire up auto-validation
_lastValidatedImageSource = '' ;
const imgSrcInput = document . getElementById ( 'stream-image-source' ) ;
imgSrcInput . value = '' ;
document . getElementById ( 'stream-image-preview-container' ) . style . display = 'none' ;
document . getElementById ( 'stream-image-validation-status' ) . style . display = 'none' ;
imgSrcInput . onblur = ( ) => validateStaticImage ( ) ;
imgSrcInput . onkeydown = ( e ) => { if ( e . key === 'Enter' ) { e . preventDefault ( ) ; validateStaticImage ( ) ; } } ;
imgSrcInput . onpaste = ( ) => setTimeout ( ( ) => validateStaticImage ( ) , 0 ) ;
onStreamTypeChange ( ) ;
// Populate dropdowns
@@ -3242,8 +3314,8 @@ async function editStream(streamId) {
if ( ! response . ok ) throw new Error ( ` Failed to load stream: ${ response . status } ` ) ;
const stream = await response . json ( ) ;
const editTitleKey = stream . stream _type === 'raw' ? 'streams.edit.raw' : 'streams.edit.processed' ;
document . getElementById ( 'stream-modal-title' ) . textContent = t ( editTitleKey ) ;
const editTitleKeys = { raw : 'streams.edit.raw' , processed : 'streams.edit.processed' , static _image : 'streams.edit.static_image' } ;
document . getElementById ( 'stream-modal-title' ) . textContent = t ( editTitleKeys [ stream . stream _type ] || 'streams.edit' ) ;
document . getElementById ( 'stream-id' ) . value = streamId ;
document . getElementById ( 'stream-name' ) . value = stream . name ;
document . getElementById ( 'stream-description' ) . value = stream . description || '' ;
@@ -3251,6 +3323,9 @@ async function editStream(streamId) {
// Set type (hidden input)
document . getElementById ( 'stream-type' ) . value = stream . stream _type ;
// Clear static image preview
document . getElementById ( 'stream-image-preview-container' ) . style . display = 'none' ;
document . getElementById ( 'stream-image-validation-status' ) . style . display = 'none' ;
onStreamTypeChange ( ) ;
// Populate dropdowns before setting values
@@ -3264,9 +3339,15 @@ async function editStream(streamId) {
const fps = stream . target _fps ? ? 30 ;
document . getElementById ( 'stream-target-fps' ) . value = fps ;
document . getElementById ( 'stream-target-fps-value' ) . textContent = fps ;
} else {
} else if ( stream . stream _type === 'processed' ) {
document . getElementById ( 'stream-source' ) . value = stream . source _stream _id || '' ;
document . getElementById ( 'stream-pp-template' ) . value = stream . postprocessing _template _id || '' ;
} else if ( stream . stream _type === 'static_image' ) {
document . getElementById ( 'stream-image-source' ) . value = stream . image _source || '' ;
// Auto-validate to show preview
if ( stream . image _source ) {
validateStaticImage ( ) ;
}
}
const modal = document . getElementById ( 'stream-modal' ) ;
@@ -3324,7 +3405,8 @@ async function populateStreamModalDropdowns() {
if ( s . id === editingId ) return ;
const opt = document . createElement ( 'option' ) ;
opt . value = s . id ;
const typeLabel = s . stream _type === 'raw' ? '🖥️' : '🎨' ;
const typeLabels = { raw : '🖥️' , processed : '🎨' , static _image : '🖼️' } ;
const typeLabel = typeLabels [ s . stream _type ] || '📺' ;
opt . textContent = ` ${ typeLabel } ${ s . name } ` ;
sourceSelect . appendChild ( opt ) ;
} ) ;
@@ -3367,9 +3449,16 @@ async function saveStream() {
payload . display _index = parseInt ( document . getElementById ( 'stream-display-index' ) . value ) || 0 ;
payload . capture _template _id = document . getElementById ( 'stream-capture-template' ) . value ;
payload . target _fps = parseInt ( document . getElementById ( 'stream-target-fps' ) . value ) || 30 ;
} else {
} else if ( streamType === 'processed' ) {
payload . source _stream _id = document . getElementById ( 'stream-source' ) . value ;
payload . postprocessing _template _id = document . getElementById ( 'stream-pp-template' ) . value ;
} else if ( streamType === 'static_image' ) {
const imageSource = document . getElementById ( 'stream-image-source' ) . value . trim ( ) ;
if ( ! imageSource ) {
showToast ( t ( 'streams.error.required' ) , 'error' ) ;
return ;
}
payload . image _source = imageSource ;
}
try {
@@ -3429,6 +3518,56 @@ function closeStreamModal() {
unlockBody ( ) ;
}
let _lastValidatedImageSource = '' ;
async function validateStaticImage ( ) {
const source = document . getElementById ( 'stream-image-source' ) . value . trim ( ) ;
const previewContainer = document . getElementById ( 'stream-image-preview-container' ) ;
const previewImg = document . getElementById ( 'stream-image-preview' ) ;
const infoEl = document . getElementById ( 'stream-image-info' ) ;
const statusEl = document . getElementById ( 'stream-image-validation-status' ) ;
if ( ! source ) {
_lastValidatedImageSource = '' ;
previewContainer . style . display = 'none' ;
statusEl . style . display = 'none' ;
return ;
}
if ( source === _lastValidatedImageSource ) return ;
// Show loading state
statusEl . textContent = t ( 'streams.validate_image.validating' ) ;
statusEl . className = 'validation-status loading' ;
statusEl . style . display = 'block' ;
previewContainer . style . display = 'none' ;
try {
const response = await fetchWithAuth ( '/picture-streams/validate-image' , {
method : 'POST' ,
body : JSON . stringify ( { image _source : source } ) ,
} ) ;
const data = await response . json ( ) ;
_lastValidatedImageSource = source ;
if ( data . valid ) {
previewImg . src = data . preview ;
infoEl . textContent = ` ${ data . width } × ${ data . height } px ` ;
previewContainer . style . display = '' ;
statusEl . textContent = t ( 'streams.validate_image.valid' ) ;
statusEl . className = 'validation-status success' ;
} else {
previewContainer . style . display = 'none' ;
statusEl . textContent = ` ${ t ( 'streams.validate_image.invalid' ) } : ${ data . error } ` ;
statusEl . className = 'validation-status error' ;
}
} catch ( err ) {
previewContainer . style . display = 'none' ;
statusEl . textContent = ` ${ t ( 'streams.validate_image.invalid' ) } : ${ err . message } ` ;
statusEl . className = 'validation-status error' ;
}
}
// ===== Picture Stream Test =====
let _currentTestStreamId = null ;