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