From 55a72af90529e7f24bec1963cf9e0f40eef3bb75 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 4 Jun 2026 05:01:05 +0000 Subject: [PATCH] fix(capture): derive audio PTS from sample count (kill 2.5s leading silence) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The persistent ~2.5s of leading silence was the master aresample=async=1 PADDING the audio to reconcile a PTS-origin mismatch: video PTS starts at frame 0 (-framerate), but -use_wallclock_as_timestamps stamped the first audio chunk at its wall-clock arrival time (~2.5s after the ffmpeg graph opened). aresample filled the gap with silence. Drop wallclock: audio PTS now comes from the 48kHz sample count starting at 0 — the same origin as video frame 0 — so the streams align with no pad. The bridge already hands live audio (backlog flushed on attach), so no rate reference is needed from wallclock. --- services/capture/src/capture-manager.js | 13 ++- services/web-ui/public/screens-ingest.jsx | 107 +++++++++++++--------- services/web-ui/public/styles-fixes.css | 43 +++++++++ 3 files changed, 116 insertions(+), 47 deletions(-) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 5878a24..c14fec1 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -720,10 +720,15 @@ class CaptureManager { '-video_size', fcSize, '-framerate', fcFps, '-i', 'pipe:0', - // Audio FIFO → ffmpeg input 1. The deltacast bridge writes a 2ch s16le - // 48kHz stream paced by the SDI slot clock (same clock as the video), - // so wallclock timestamps + master aresample=async=1 keep A/V locked. - '-use_wallclock_as_timestamps', '1', + // Audio FIFO → ffmpeg input 1. The bridge flushes its slot backlog to + // the live edge on reader-attach, so the FIFO delivers live audio in + // lockstep with the video. We DERIVE audio PTS from the s16le sample + // count starting at 0 — the SAME origin as the video's frame-0 PTS — + // rather than from wall-clock arrival. Wall-clock stamped the first + // audio chunk at a time offset from video frame 0, and the master + // aresample then PADDED ~2.5s of leading silence to align them. With + // sample-count PTS both streams share one origin → no pad, no leading + // silence, length locked. '-thread_queue_size', '512', '-f', 's16le', '-ar', '48000', diff --git a/services/web-ui/public/screens-ingest.jsx b/services/web-ui/public/screens-ingest.jsx index 6e7ba41..04022d7 100644 --- a/services/web-ui/public/screens-ingest.jsx +++ b/services/web-ui/public/screens-ingest.jsx @@ -616,7 +616,6 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) { const [codec, setCodec] = React.useState(recorder.recording_codec || 'hevc_nvenc'); const [bitrate, setBitrate] = React.useState((recorder.recording_video_bitrate || '25').replace(/M$/i, '')); const [growing, setGrowing] = React.useState(recorder.growing_enabled === true); - const [audioCh, setAudioCh] = React.useState(recorder.recording_audio_channels || 2); const [projectId, setProjectId] = React.useState(recorder.project_id || PROJECTS[0]?.id || ''); const [saving, setSaving] = React.useState(false); const [err, setErr] = React.useState(null); @@ -634,7 +633,6 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) { label: label.trim() || null, recording_codec: effCodec, growing_enabled: growing, - recording_audio_channels: Number(audioCh), project_id: projectId || null, }; if (showBitrate && bitrate) body.recording_video_bitrate = String(bitrate).replace(/M$/i, '') + 'M'; @@ -680,41 +678,59 @@ function RecorderConfigModal({ recorder, onClose, onSaved }) { + {/* Recording mode — clean segmented control instead of a tickbox. */}
- - + +
+ + +
+
+ {growing + ? 'Writes a growing XDCAM HD422 MXF (OP1a) to the SMB share so editors can cut the clip live in Premiere.' + : 'Encodes a GPU master (HEVC/H.264) streamed straight to the library on stop.'} +
- {showBitrate && ( -
- - setBitrate(e.target.value)} /> + {/* Codec + bitrate only apply to Standard mode (growing is fixed XDCAM). */} + {!growing && ( +
+
+ + +
+ {showBitrate && ( +
+ + setBitrate(e.target.value)} /> +
+ )}
)} -
- - -
- SDI embeds up to 16 channels; the master keeps the first N (discrete, no downmix). -
-
-
setGrowing(e.target.checked)} /> -
-
Growing-file (edit-while-record)
-
- Writes a growing XDCAM HD422 MXF to the SMB share so editors can cut it live. -
-
- - {err &&
{err}
}
@@ -805,6 +810,10 @@ function Recorders({ navigate, onNew }) { // Per-recorder config editor (codec / growing / label). Null = closed. const [configRecorder, setConfigRecorder] = React.useState(null); + // Bump when the cluster snapshot updates so the node-grouping re-derives + // online/offline state without waiting for the recorder list to change. + const [nodesTick, setNodesTick] = React.useState(0); + const refresh = React.useCallback(() => { window.ZAMPP_API.fetch('/recorders') .then(raw => { @@ -818,6 +827,18 @@ function Recorders({ navigate, onNew }) { if (err && err.message && err.message.includes('Unauthenticated')) return; window.DF_LOG.warn('[recorders] poll error:', err?.message); }); + // ALSO refresh the cluster-node snapshot — the recorder page groups by node + // and shows each node's online/offline state via ZAMPP_DATA.NODES. Without + // this the snapshot goes stale while idling here (nodes wrongly show offline + // even though they're heartbeating). Best-effort; failure leaves last-known. + window.ZAMPP_API.fetch('/cluster') + .then(nodes => { + if (Array.isArray(nodes)) { + window.ZAMPP_DATA.NODES = nodes; + setNodesTick(t => t + 1); + } + }) + .catch(() => {}); }, []); React.useEffect(() => { @@ -857,7 +878,7 @@ function Recorders({ navigate, onNew }) { } out.sort((a, b) => a.meta.hostname.localeCompare(b.meta.hostname)); return out; - }, [recorders]); + }, [recorders, nodesTick]); return (
diff --git a/services/web-ui/public/styles-fixes.css b/services/web-ui/public/styles-fixes.css index 4117765..4f4585d 100644 --- a/services/web-ui/public/styles-fixes.css +++ b/services/web-ui/public/styles-fixes.css @@ -1126,3 +1126,46 @@ button.btn.primary:active { .recorder-take-project, .recorder-take-clip { width: auto; flex: 1; min-width: 110px; } } + +/* ── Recorder config modal — recording-mode segmented control + grid ───────── */ +.rec-mode-seg { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 8px; +} +.rec-mode-opt { + display: flex; + align-items: center; + gap: 9px; + padding: 10px 12px; + border: 1px solid var(--border); + border-radius: 9px; + background: var(--bg-2, rgba(255,255,255,0.02)); + color: var(--text-2); + cursor: pointer; + text-align: left; + transition: border-color .12s ease, background .12s ease, color .12s ease; +} +.rec-mode-opt:hover:not(:disabled) { border-color: var(--accent, #4a9eff); } +.rec-mode-opt.active { + border-color: var(--accent, #4a9eff); + background: var(--accent-soft, rgba(74,158,255,0.12)); + color: var(--text-1); +} +.rec-mode-opt:disabled { opacity: .55; cursor: default; } +.rec-mode-opt .icon { flex-shrink: 0; opacity: .85; } +.rec-mode-txt { display: flex; flex-direction: column; line-height: 1.25; } +.rec-mode-name { font-size: 13px; font-weight: 600; } +.rec-mode-desc { font-size: 10.5px; color: var(--text-3); } +.rec-mode-hint { + margin-top: 8px; + font-size: 11px; + line-height: 1.5; + color: var(--text-3); +} +.rec-cfg-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 12px; +} +.rec-cfg-grid .field:only-child { grid-column: 1 / -1; }