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 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-31 12:03:20 -04:00
parent 12115a053a
commit f7cf56ae0d
4 changed files with 101 additions and 23 deletions

View file

@ -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 }) {
<button
key={t.id}
className={'launcher-tile tone-' + t.tone}
onClick={() => t.id === '__premiere' ? setShowPremiereDownload(true) : navigate(t.id)}
onClick={() => t.id === '__downloads' ? setShowDownloads(true) : navigate(t.id)}
>
<span className="launcher-tile-icon">
<Icon name={t.icon} size={26} />
@ -241,39 +249,46 @@ function Home({ navigate }) {
)}
</div>
</div>
{showPremiereDownload && <PremiereDownloadModal onClose={() => setShowPremiereDownload(false)} />}
{showDownloads && <DownloadsModal onClose={() => setShowDownloads(false)} />}
</div>
);
}
// Modal listing all Premiere panel downloads (ZXP + Windows installer for
// each released version). Sourced from window.PREMIERE_RELEASES, written by
// the Settings SDKs section in screens-admin.jsx.
function PremiereDownloadModal({ onClose }) {
// Combined downloads modal: Premiere Pro panel + Dragon-ISO.
function DownloadsModal({ onClose }) {
const releases = (window.PREMIERE_RELEASES || []).slice().sort((a, b) => {
// Newest first; fall back to lexicographic compare on version string.
const av = String(a.version || ''), bv = String(b.version || '');
return bv.localeCompare(av, undefined, { numeric: true });
});
const latest = window.PREMIERE_LATEST || releases[0] || null;
const DRAGON_ISO_RELEASES_URL = 'https://forge.wilddragon.net/WildDragonLLC/dragon-iso/releases';
return (
<div className="modal-backdrop" onClick={onClose}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 560 }}>
<div className="modal" onClick={(e) => e.stopPropagation()} style={{ maxWidth: 580 }}>
<div className="modal-head">
<div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Premiere panel</div>
<div style={{ fontSize: 15, fontWeight: 600 }}>Downloads</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginTop: 2 }}>
Adobe Premiere Pro (UXP) integration. Install the .ccx per workstation via the Adobe UXP Developer Tool, or double-click it with Creative Cloud installed.
Premiere Pro panel and Dragon-ISO NDI tools.
</div>
</div>
<button className="icon-btn" aria-label="Close" onClick={onClose}><Icon name="x" /></button>
</div>
<div className="modal-body">
{/* ── Premiere panel ── */}
<div className="downloads-section-head">
<Icon name="editor" size={13} />
<span>Premiere Pro panel (UXP)</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
Install the .ccx per workstation via the Adobe UXP Developer Tool, or double-click it with Creative Cloud installed.
</div>
{releases.length === 0 && (
<div style={{ padding: '24px 0', textAlign: 'center', color: 'var(--text-3)', fontSize: 12 }}>
No releases registered yet. Upload one from Settings Capture SDKs.
<div style={{ padding: '12px 0', color: 'var(--text-3)', fontSize: 12 }}>
No releases registered. Upload one from Settings Capture SDKs.
</div>
)}
{releases.map((rel, i) => (
@ -293,23 +308,39 @@ function PremiereDownloadModal({ onClose }) {
<div className="premiere-release-actions">
{rel.ccx && (
<a href={rel.ccx} download className="btn primary sm">
<Icon name="download" />UXP plugin (.ccx)
<Icon name="download" size={12} />UXP plugin (.ccx)
</a>
)}
{rel.installer && (
<a href={rel.installer} download className="btn ghost sm">
<Icon name="download" />Windows installer
<Icon name="download" size={12} />Windows installer
</a>
)}
</div>
</div>
))}
{/* ── Dragon-ISO ── */}
<div className="downloads-section-head" style={{ marginTop: 20 }}>
<Icon name="film" size={13} />
<span>Dragon-ISO</span>
</div>
<div style={{ fontSize: 12, color: 'var(--text-3)', marginBottom: 10 }}>
NDI ISO recorder for Microsoft Teams. Windows, .NET 8 WPF.
</div>
<div className="premiere-release">
<div className="premiere-release-head">
<span className="premiere-release-version mono">Releases</span>
</div>
<div className="premiere-release-actions">
<a href={DRAGON_ISO_RELEASES_URL} target="_blank" rel="noopener noreferrer" className="btn primary sm">
<Icon name="download" size={12} />View releases on Forgejo
</a>
</div>
</div>
</div>
<div className="modal-foot">
<div style={{ flex: 1, fontSize: 11.5, color: 'var(--text-3)' }}>
Need help installing? Use the Adobe Extension Manager or UPIA.
</div>
<button className="btn ghost" onClick={onClose}>Close</button>
</div>
</div>

View file

@ -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'
? <button className="btn danger" onClick={stopChannel}>Stop channel</button>
: <button className="btn primary" onClick={startChannel}>Start channel</button>}
{ch.status !== 'running' && (
<button className="btn ghost danger sm" onClick={deleteChannel} title="Delete this channel">Delete</button>
)}
</div>
</div>
{ch.error_message && <div className="alert error">{ch.error_message}</div>}
@ -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));
};

View file

@ -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; }

View file

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