From f7cf56ae0dfe1041653f6c5a24efe66e2ceb51a7 Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Sun, 31 May 2026 12:03:20 -0400 Subject: [PATCH] fix(playout): silent-audio staging crash, home tiles, channel delete - playout-stage: skip loudnorm pass 2 when measured_I=-inf (silent or no-audio clip); fall back to plain AAC transcode so staging completes instead of erroring out - screens-home: add Playout tile; replace Premiere panel tile with Downloads tile opening a combined modal (Premiere panel releases + Dragon-ISO link to forge.wilddragon.net/WildDragonLLC/dragon-iso) - screens-playout: add Delete channel button (visible only when stopped); removes channel from list and selects next on confirm Co-Authored-By: Claude Sonnet 4.6 --- services/web-ui/public/screens-home.jsx | 77 ++++++++++++++------ services/web-ui/public/screens-playout.jsx | 18 +++++ services/web-ui/public/styles-playout.css | 9 +++ services/worker/src/workers/playout-stage.js | 20 +++++ 4 files changed, 101 insertions(+), 23 deletions(-) diff --git a/services/web-ui/public/screens-home.jsx b/services/web-ui/public/screens-home.jsx index fb1b71a..89ebcad 100644 --- a/services/web-ui/public/screens-home.jsx +++ b/services/web-ui/public/screens-home.jsx @@ -18,7 +18,7 @@ // Anything that would just say "all clear" is hidden, not rendered. function Home({ navigate }) { - const [showPremiereDownload, setShowPremiereDownload] = React.useState(false); + const [showDownloads, setShowDownloads] = React.useState(false); // Pull live counts so the tile subtitles ("34 assets", "0 live", "3 running") // reflect what's actually in the DB right now, not a stale boot-time cache. @@ -64,12 +64,20 @@ function Home({ navigate }) { desc: 'SDI · SRT · RTMP ingest. Start, stop, schedule.', }, { - id: '__premiere', - label: 'Premiere panel', - icon: 'editor', + id: 'playout', + label: 'Playout', + icon: 'monitor', + tone: 'live', + sub: 'Master Control', + desc: 'Play assets to SDI, NDI, SRT or RTMP via CasparCG.', + }, + { + id: '__downloads', + label: 'Downloads', + icon: 'download', tone: 'purple', - sub: 'v' + ((window.PREMIERE_LATEST || {}).version || '·'), - desc: 'Download the Adobe Premiere Pro panel for frame-accurate editing.', + sub: 'Premiere panel · Dragon-ISO', + desc: 'Download the Premiere Pro panel and Dragon-ISO NDI tools.', }, { id: 'jobs', @@ -127,7 +135,7 @@ function Home({ navigate }) {
+ {/* ── Premiere panel ── */} +
+ + Premiere Pro panel (UXP) +
+
+ Install the .ccx per workstation via the Adobe UXP Developer Tool, or double-click it with Creative Cloud installed. +
{releases.length === 0 && ( -
- No releases registered yet. Upload one from Settings → Capture SDKs. +
+ No releases registered. Upload one from Settings → Capture SDKs.
)} {releases.map((rel, i) => ( @@ -293,23 +308,39 @@ function PremiereDownloadModal({ onClose }) {
{rel.ccx && ( - UXP plugin (.ccx) + UXP plugin (.ccx) )} {rel.installer && ( - Windows installer + Windows installer )}
))} + + {/* ── Dragon-ISO ── */} +
+ + Dragon-ISO +
+
+ NDI ISO recorder for Microsoft Teams. Windows, .NET 8 WPF. +
+
+
+ Releases +
+
+ + View releases on Forgejo + +
+
-
- Need help installing? Use the Adobe Extension Manager or UPIA. -
diff --git a/services/web-ui/public/screens-playout.jsx b/services/web-ui/public/screens-playout.jsx index 965d32c..f214dcb 100644 --- a/services/web-ui/public/screens-playout.jsx +++ b/services/web-ui/public/screens-playout.jsx @@ -456,6 +456,13 @@ function ChannelDetail({ channel, onChannelChange }) { const updated = await poFetch('/channels/' + channel.id + '/stop', { method: 'POST' }); setCh(updated); onChannelChange(updated); }; + const deleteChannel = async () => { + if (!window.confirm('Delete channel "' + ch.name + '"? This cannot be undone.')) return; + try { + await poFetch('/channels/' + ch.id, { method: 'DELETE' }); + onChannelChange({ ...ch, _deleted: true }); + } catch (e) { alert(e.message); } + }; // engine.currentIndex maps directly to the sorted item position. const activeIndex = (engine && engine.currentIndex >= 0) ? engine.currentIndex : -1; @@ -471,6 +478,9 @@ function ChannelDetail({ channel, onChannelChange }) { {ch.status === 'running' ? : } + {ch.status !== 'running' && ( + + )} {ch.error_message &&
{ch.error_message}
} @@ -514,6 +524,14 @@ function Playout() { const selected = (channels || []).find(c => c.id === selectedId) || null; const onChannelChange = (updated) => { + if (updated._deleted) { + setChannels(cs => { + const next = (cs || []).filter(c => c.id !== updated.id); + setSelectedId(next.length ? next[0].id : null); + return next; + }); + return; + } setChannels(cs => (cs || []).map(c => c.id === updated.id ? updated : c)); }; diff --git a/services/web-ui/public/styles-playout.css b/services/web-ui/public/styles-playout.css index 3882822..1699146 100644 --- a/services/web-ui/public/styles-playout.css +++ b/services/web-ui/public/styles-playout.css @@ -156,6 +156,15 @@ } .po-pl-total { color: var(--text-2); } +/* Downloads modal section header */ +.downloads-section-head { + display: flex; align-items: center; gap: 6px; + font-size: 11px; font-weight: 600; text-transform: uppercase; + letter-spacing: 0.06em; color: var(--text-3); + padding-bottom: 8px; border-bottom: 1px solid var(--border); + margin-bottom: 10px; +} + /* Small button variants reused */ .btn.xs { padding: 2px 8px; font-size: 11px; } .btn.sm { padding: 5px 10px; font-size: 12px; } diff --git a/services/worker/src/workers/playout-stage.js b/services/worker/src/workers/playout-stage.js index b65066a..3a3ccb6 100644 --- a/services/worker/src/workers/playout-stage.js +++ b/services/worker/src/workers/playout-stage.js @@ -57,10 +57,30 @@ async function measureLoudness(inputPath) { return JSON.parse(match[0]); } +function isFiniteLoudness(val) { + const n = parseFloat(val); + return isFinite(n); +} + async function applyLoudnorm(inputPath, outputPath, m) { // Pass 2: linear normalization using pass 1's measurements. -c:v copy keeps // the video stream intact so we only re-encode audio (target AAC stereo, the // common-denominator CasparCG ffmpeg producer accepts). + // + // Silent / no-audio clips measure I=-inf which ffmpeg rejects in pass 2. + // When any loudnorm measurement is non-finite, fall back to a plain audio + // transcode (AAC 192k) with no loudness adjustment — the clip has no + // meaningful audio to normalize. + const silentOrNoAudio = !isFiniteLoudness(m.input_i) || !isFiniteLoudness(m.input_tp); + if (silentOrNoAudio) { + console.log(`[playout-stage] loudnorm skip — silent/no audio (I=${m.input_i}), transcoding audio only`); + await runFfmpeg([ + '-hide_banner', '-nostats', '-y', '-i', inputPath, + '-c:v', 'copy', '-c:a', 'aac', '-b:a', '192k', '-ar', '48000', + outputPath, + ]); + return; + } await runFfmpeg([ '-hide_banner', '-nostats', '-y', '-i', inputPath, '-af', `loudnorm=I=-23:TP=-1:LRA=11:measured_I=${m.input_i}:measured_TP=${m.input_tp}:measured_LRA=${m.input_lra}:measured_thresh=${m.input_thresh}:offset=${m.target_offset}:linear=true:print_format=summary`,