From f4a83eedc4525aa176652d4c0a6261c612f454c8 Mon Sep 17 00:00:00 2001 From: ZGaetano Date: Thu, 21 May 2026 00:19:00 -0400 Subject: [PATCH] capture-manager: dynamic ffmpeg args from per-recorder codec config Adds a VIDEO_CODECS / AUDIO_CODECS / CONTAINER catalogue and a buildEncodeArgs() that composes -c:v / -c:a / -b:v / -b:a / -r / -ac / -f from the recorder's saved settings. Master and proxy each get their own codec stack, both honour the container format chosen in the UI, and the S3 keys now use the actual container extension instead of hardcoded mov/mp4. --- services/capture/src/capture-manager.js | 232 +++++++++++++++--------- 1 file changed, 142 insertions(+), 90 deletions(-) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 465071d..feee30f 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -4,6 +4,73 @@ import { createUploadStream } from './s3/client.js'; const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon'; +// ── Codec catalogue ────────────────────────────────────────────────────── +// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate +// / pix_fmt are layered on top from the per-recorder configuration. +const VIDEO_CODECS = { + prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false }, + prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false }, + prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false }, + prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false }, + dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true }, + dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false }, + dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false }, + h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true }, + h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true }, + h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true }, + hevc_nvenc: { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'], bitrateControl: true }, +}; + +const AUDIO_CODECS = { + pcm_s16le: { args: ['-c:a', 'pcm_s16le'], bitrateControl: false }, + pcm_s24le: { args: ['-c:a', 'pcm_s24le'], bitrateControl: false }, + pcm_s32le: { args: ['-c:a', 'pcm_s32le'], bitrateControl: false }, + aac: { args: ['-c:a', 'aac'], bitrateControl: true }, + ac3: { args: ['-c:a', 'ac3'], bitrateControl: true }, + opus: { args: ['-c:a', 'libopus'], bitrateControl: true }, + flac: { args: ['-c:a', 'flac'], bitrateControl: false }, +}; + +const CONTAINER_FMT = { + mov: 'mov', + mp4: 'mp4', + mkv: 'matroska', + mxf: 'mxf', + ts: 'mpegts', +}; + +const CONTAINER_EXT = { + mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts', +}; + +function buildEncodeArgs({ + codec, videoBitrate, framerate, + audioCodec, audioBitrate, audioChannels, + container, isNetwork, isProxy = false, +}) { + const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq); + const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le); + const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov'); + + const args = []; + if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?'); + + args.push(...v.args); + if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate); + if (framerate && framerate !== 'native') args.push('-r', framerate); + + args.push(...a.args); + if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate); + if (audioChannels) args.push('-ac', String(audioChannels)); + + if (isNetwork && (fmt === 'mov' || fmt === 'mp4')) { + args.push('-movflags', '+frag_keyframe+empty_moov'); + } + args.push('-f', fmt); + + return args; +} + class CaptureManager { constructor() { this.state = { @@ -11,7 +78,6 @@ class CaptureManager { sessionId: null, processes: {}, currentSession: {}, - // Live signal metrics derived from ffmpeg stderr framesReceived: 0, currentFps: 0, lastFrameAt: null, @@ -31,7 +97,6 @@ class CaptureManager { const port = listenPort || 9000; url = `srt://0.0.0.0:${port}?mode=listener`; } else { - // Caller mode — ensure mode=caller is appended if not already present url = sourceUrl; if (!url.includes('mode=')) { url += (url.includes('?') ? '&' : '?') + 'mode=caller'; @@ -60,16 +125,10 @@ class CaptureManager { } /** - * Start a new capture session - * @param {Object} params - * - projectId, binId, clipName — always required - * - device — DeckLink device index (SDI only) - * - sourceType — 'sdi' | 'srt' | 'rtmp' (default: 'sdi') - * - sourceUrl — URL for caller mode (SRT/RTMP caller) - * - listen — true for listener/server mode - * - listenPort — port to bind in listener mode - * - streamKey — RTMP stream key for listener mode - * @returns {Object} Session info + * Start a new capture session. + * + * Codec parameters all have sensible defaults so legacy callers (no codec + * args) still produce ProRes HQ master + H.264 proxy. */ async start({ assetId, @@ -82,6 +141,23 @@ class CaptureManager { listen = false, listenPort, streamKey, + // ── Recording codec ───────────────────────────────────────────── + videoCodec = 'prores_hq', + videoBitrate = null, + framerate = null, + audioCodec = 'pcm_s24le', + audioBitrate = null, + audioChannels = 2, + container = 'mov', + // ── Proxy codec ───────────────────────────────────────────────── + proxyEnabled = true, + proxyVideoCodec = 'h264', + proxyVideoBitrate = '8M', + proxyFramerate = null, + proxyAudioCodec = 'aac', + proxyAudioBitrate = '192k', + proxyAudioChannels = 2, + proxyContainer = 'mp4', }) { this._assetIdForHls = assetId || null; if (this.state.recording) { @@ -89,57 +165,45 @@ class CaptureManager { } const sessionId = uuidv4(); - const hiresKey = `projects/${projectId}/masters/${clipName}.mov`; + const hiresExt = CONTAINER_EXT[container] || 'mov'; + const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4'; + const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`; - // Network sources cannot be opened by two FFmpeg processes simultaneously. - // proxyKey is null for SRT/RTMP — the BullMQ worker generates the proxy - // after the recording stops (same pipeline used for uploaded files). - const proxyKey = sourceType === 'sdi' - ? `projects/${projectId}/proxies/${clipName}.mp4` + // Network sources cannot be opened by two FFmpeg processes simultaneously + // (one socket = one consumer). For SRT/RTMP the BullMQ worker generates + // the proxy after the recording stops. + const proxyKey = (sourceType === 'sdi' && proxyEnabled) + ? `projects/${projectId}/proxies/${clipName}.${proxyExt}` : null; const startedAt = new Date().toISOString(); const { inputArgs, isNetwork } = this._buildInputArgs({ - sourceType, - device, - sourceUrl, - listen, - listenPort, - streamKey, + sourceType, device, sourceUrl, listen, listenPort, streamKey, }); - // ProRes hires — fragmented moov for pipe-safe output on network sources - const hiresCodecArgs = isNetwork - ? [ - '-map', '0:v:0?', - '-map', '0:a:0?', - '-c:v', 'prores_ks', - '-profile:v', '3', - '-c:a', 'pcm_s24le', - '-movflags', '+frag_keyframe+empty_moov', - '-f', 'mov', - ] - : [ - '-c:v', 'prores_ks', - '-profile:v', '3', - '-c:a', 'pcm_s24le', - '-f', 'mov', - ]; + const hiresCodecArgs = buildEncodeArgs({ + codec: videoCodec, videoBitrate, framerate, + audioCodec, audioBitrate, audioChannels, + container, + isNetwork, + isProxy: false, + }); + + console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' ')); - // Spawn hires FFmpeg process const hiresProcess = spawn('ffmpeg', [ ...inputArgs, ...hiresCodecArgs, 'pipe:1', - ], { - stdio: ['ignore', 'pipe', 'pipe'], - }); + ], { stdio: ['ignore', 'pipe', 'pipe'] }); const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout); const processes = { hires: hiresProcess }; const uploads = { hires: hiresUpload }; + + // ── HLS tee for network sources (live preview in the UI) ────────── let hlsProcess = null; let hlsDir = null; if (isNetwork && this._assetIdForHls) { @@ -168,40 +232,43 @@ class CaptureManager { } } - hiresProcess.stderr.on('data', (data) => { const text = data.toString(); console.error(`[HIRES] ${text}`); - // Track stream signal: ffmpeg prints "frame= 123 fps= 30 ..." every ~1s const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/); if (m) { this.state.framesReceived = parseInt(m[1], 10); this.state.currentFps = parseFloat(m[2]); this.state.lastFrameAt = new Date().toISOString(); } - // Surface fatal-looking lines for the status endpoint if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) { this.state.lastError = text.trim().slice(0, 240); } }); - // SDI only: spawn a second FFmpeg process for the proxy. - // DeckLink cards can be opened simultaneously by multiple processes; - // network streams cannot. - if (!isNetwork) { + // SDI only: spawn a second ffmpeg for the proxy. + // DeckLink cards allow concurrent reads; network sockets do not. + if (!isNetwork && proxyEnabled) { + const proxyCodecArgs = buildEncodeArgs({ + codec: proxyVideoCodec, + videoBitrate: proxyVideoBitrate, + framerate: proxyFramerate, + audioCodec: proxyAudioCodec, + audioBitrate: proxyAudioBitrate, + audioChannels: proxyAudioChannels, + container: proxyContainer, + isNetwork: false, + isProxy: true, + }); + + console.log('[capture] proxy ffmpeg args:', proxyCodecArgs.join(' ')); + const proxyProcess = spawn('ffmpeg', [ ...inputArgs, - '-c:v', 'libx264', - '-preset', 'fast', - '-b:v', '10M', - '-c:a', 'aac', - '-b:a', '192k', + ...proxyCodecArgs, '-movflags', '+frag_keyframe+empty_moov', - '-f', 'mp4', 'pipe:1', - ], { - stdio: ['ignore', 'pipe', 'pipe'], - }); + ], { stdio: ['ignore', 'pipe', 'pipe'] }); const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout); processes.proxy = proxyProcess; @@ -232,16 +299,17 @@ class CaptureManager { startedAt, duration: 0, uploads, + codecs: { + videoCodec, videoBitrate, framerate, + audioCodec, audioBitrate, audioChannels, container, + proxyEnabled, proxyVideoCodec, proxyVideoBitrate, + proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer, + }, }; return this._formatSessionResponse(); } - /** - * Stop the current capture session - * @param {string} sessionId - Session ID to stop - * @returns {Object} Completed session info - */ async stop(sessionId) { if (!this.state.recording || this.state.sessionId !== sessionId) { throw new Error('No active capture session or session ID mismatch'); @@ -249,19 +317,13 @@ class CaptureManager { const { processes, currentSession } = this.state; - // Gracefully terminate all FFmpeg processes - if (processes.hires) { - processes.hires.kill('SIGINT'); - } + if (processes.hires) processes.hires.kill('SIGINT'); if (processes.proxy) processes.proxy.kill('SIGINT'); - if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } + if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } try { - // Wait for all in-flight S3 uploads to complete const uploadPromises = [currentSession.uploads.hires]; - if (currentSession.uploads.proxy) { - uploadPromises.push(currentSession.uploads.proxy); - } + if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy); await Promise.all(uploadPromises); } catch (error) { console.error('Error during upload completion:', error); @@ -272,7 +334,6 @@ class CaptureManager { const stopTime = new Date(stoppedAt); const duration = Math.round((stopTime - startTime) / 1000); - // Reset state this.state.recording = false; this.state.sessionId = null; this.state.processes = {}; @@ -284,23 +345,15 @@ class CaptureManager { clipName: currentSession.clipName, sourceType: currentSession.sourceType, hiresKey: currentSession.hiresKey, - proxyKey: currentSession.proxyKey, // null for SRT/RTMP + proxyKey: currentSession.proxyKey, startedAt: currentSession.startedAt, stoppedAt, duration, }; } - /** - * Get current capture status - * @returns {Object} Current state - */ getStatus() { - if (!this.state.recording) { - return { - recording: false, - }; - } + if (!this.state.recording) return { recording: false }; const startTime = new Date(this.state.currentSession.startedAt); const now = new Date(); @@ -330,13 +383,10 @@ class CaptureManager { lastFrameAt, msSinceFrame, lastError: this.state.lastError, + codecs: this.state.currentSession.codecs, }; } - /** - * Format session response - * @private - */ _formatSessionResponse() { const { currentSession, sessionId } = this.state; return { @@ -349,8 +399,10 @@ class CaptureManager { hiresKey: currentSession.hiresKey, proxyKey: currentSession.proxyKey, startedAt: currentSession.startedAt, + codecs: currentSession.codecs, }; } } export default new CaptureManager(); +export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };