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.
This commit is contained in:
Zac Gaetano 2026-05-21 00:19:00 -04:00
parent 4c65753358
commit f4a83eedc4

View file

@ -4,6 +4,73 @@ import { createUploadStream } from './s3/client.js';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon'; 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 { class CaptureManager {
constructor() { constructor() {
this.state = { this.state = {
@ -11,7 +78,6 @@ class CaptureManager {
sessionId: null, sessionId: null,
processes: {}, processes: {},
currentSession: {}, currentSession: {},
// Live signal metrics derived from ffmpeg stderr
framesReceived: 0, framesReceived: 0,
currentFps: 0, currentFps: 0,
lastFrameAt: null, lastFrameAt: null,
@ -31,7 +97,6 @@ class CaptureManager {
const port = listenPort || 9000; const port = listenPort || 9000;
url = `srt://0.0.0.0:${port}?mode=listener`; url = `srt://0.0.0.0:${port}?mode=listener`;
} else { } else {
// Caller mode — ensure mode=caller is appended if not already present
url = sourceUrl; url = sourceUrl;
if (!url.includes('mode=')) { if (!url.includes('mode=')) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller'; url += (url.includes('?') ? '&' : '?') + 'mode=caller';
@ -60,16 +125,10 @@ class CaptureManager {
} }
/** /**
* Start a new capture session * Start a new capture session.
* @param {Object} params *
* - projectId, binId, clipName always required * Codec parameters all have sensible defaults so legacy callers (no codec
* - device DeckLink device index (SDI only) * args) still produce ProRes HQ master + H.264 proxy.
* - 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
*/ */
async start({ async start({
assetId, assetId,
@ -82,6 +141,23 @@ class CaptureManager {
listen = false, listen = false,
listenPort, listenPort,
streamKey, 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; this._assetIdForHls = assetId || null;
if (this.state.recording) { if (this.state.recording) {
@ -89,57 +165,45 @@ class CaptureManager {
} }
const sessionId = uuidv4(); 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. // Network sources cannot be opened by two FFmpeg processes simultaneously
// proxyKey is null for SRT/RTMP — the BullMQ worker generates the proxy // (one socket = one consumer). For SRT/RTMP the BullMQ worker generates
// after the recording stops (same pipeline used for uploaded files). // the proxy after the recording stops.
const proxyKey = sourceType === 'sdi' const proxyKey = (sourceType === 'sdi' && proxyEnabled)
? `projects/${projectId}/proxies/${clipName}.mp4` ? `projects/${projectId}/proxies/${clipName}.${proxyExt}`
: null; : null;
const startedAt = new Date().toISOString(); const startedAt = new Date().toISOString();
const { inputArgs, isNetwork } = this._buildInputArgs({ const { inputArgs, isNetwork } = this._buildInputArgs({
sourceType, sourceType, device, sourceUrl, listen, listenPort, streamKey,
device,
sourceUrl,
listen,
listenPort,
streamKey,
}); });
// ProRes hires — fragmented moov for pipe-safe output on network sources const hiresCodecArgs = buildEncodeArgs({
const hiresCodecArgs = isNetwork codec: videoCodec, videoBitrate, framerate,
? [ audioCodec, audioBitrate, audioChannels,
'-map', '0:v:0?', container,
'-map', '0:a:0?', isNetwork,
'-c:v', 'prores_ks', isProxy: false,
'-profile:v', '3', });
'-c:a', 'pcm_s24le',
'-movflags', '+frag_keyframe+empty_moov', console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
'-f', 'mov',
]
: [
'-c:v', 'prores_ks',
'-profile:v', '3',
'-c:a', 'pcm_s24le',
'-f', 'mov',
];
// Spawn hires FFmpeg process
const hiresProcess = spawn('ffmpeg', [ const hiresProcess = spawn('ffmpeg', [
...inputArgs, ...inputArgs,
...hiresCodecArgs, ...hiresCodecArgs,
'pipe:1', 'pipe:1',
], { ], { stdio: ['ignore', 'pipe', 'pipe'] });
stdio: ['ignore', 'pipe', 'pipe'],
});
const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout); const hiresUpload = createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout);
const processes = { hires: hiresProcess }; const processes = { hires: hiresProcess };
const uploads = { hires: hiresUpload }; const uploads = { hires: hiresUpload };
// ── HLS tee for network sources (live preview in the UI) ──────────
let hlsProcess = null; let hlsProcess = null;
let hlsDir = null; let hlsDir = null;
if (isNetwork && this._assetIdForHls) { if (isNetwork && this._assetIdForHls) {
@ -168,40 +232,43 @@ class CaptureManager {
} }
} }
hiresProcess.stderr.on('data', (data) => { hiresProcess.stderr.on('data', (data) => {
const text = data.toString(); const text = data.toString();
console.error(`[HIRES] ${text}`); 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.]+)/); const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
if (m) { if (m) {
this.state.framesReceived = parseInt(m[1], 10); this.state.framesReceived = parseInt(m[1], 10);
this.state.currentFps = parseFloat(m[2]); this.state.currentFps = parseFloat(m[2]);
this.state.lastFrameAt = new Date().toISOString(); 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)) { 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); this.state.lastError = text.trim().slice(0, 240);
} }
}); });
// SDI only: spawn a second FFmpeg process for the proxy. // SDI only: spawn a second ffmpeg for the proxy.
// DeckLink cards can be opened simultaneously by multiple processes; // DeckLink cards allow concurrent reads; network sockets do not.
// network streams cannot. if (!isNetwork && proxyEnabled) {
if (!isNetwork) { 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', [ const proxyProcess = spawn('ffmpeg', [
...inputArgs, ...inputArgs,
'-c:v', 'libx264', ...proxyCodecArgs,
'-preset', 'fast',
'-b:v', '10M',
'-c:a', 'aac',
'-b:a', '192k',
'-movflags', '+frag_keyframe+empty_moov', '-movflags', '+frag_keyframe+empty_moov',
'-f', 'mp4',
'pipe:1', 'pipe:1',
], { ], { stdio: ['ignore', 'pipe', 'pipe'] });
stdio: ['ignore', 'pipe', 'pipe'],
});
const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout); const proxyUpload = createUploadStream(S3_BUCKET, proxyKey, proxyProcess.stdout);
processes.proxy = proxyProcess; processes.proxy = proxyProcess;
@ -232,16 +299,17 @@ class CaptureManager {
startedAt, startedAt,
duration: 0, duration: 0,
uploads, uploads,
codecs: {
videoCodec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels, container,
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
},
}; };
return this._formatSessionResponse(); return this._formatSessionResponse();
} }
/**
* Stop the current capture session
* @param {string} sessionId - Session ID to stop
* @returns {Object} Completed session info
*/
async stop(sessionId) { async stop(sessionId) {
if (!this.state.recording || this.state.sessionId !== sessionId) { if (!this.state.recording || this.state.sessionId !== sessionId) {
throw new Error('No active capture session or session ID mismatch'); throw new Error('No active capture session or session ID mismatch');
@ -249,19 +317,13 @@ class CaptureManager {
const { processes, currentSession } = this.state; 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.proxy) processes.proxy.kill('SIGINT');
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} } if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
try { try {
// Wait for all in-flight S3 uploads to complete
const uploadPromises = [currentSession.uploads.hires]; const uploadPromises = [currentSession.uploads.hires];
if (currentSession.uploads.proxy) { if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
uploadPromises.push(currentSession.uploads.proxy);
}
await Promise.all(uploadPromises); await Promise.all(uploadPromises);
} catch (error) { } catch (error) {
console.error('Error during upload completion:', error); console.error('Error during upload completion:', error);
@ -272,7 +334,6 @@ class CaptureManager {
const stopTime = new Date(stoppedAt); const stopTime = new Date(stoppedAt);
const duration = Math.round((stopTime - startTime) / 1000); const duration = Math.round((stopTime - startTime) / 1000);
// Reset state
this.state.recording = false; this.state.recording = false;
this.state.sessionId = null; this.state.sessionId = null;
this.state.processes = {}; this.state.processes = {};
@ -284,23 +345,15 @@ class CaptureManager {
clipName: currentSession.clipName, clipName: currentSession.clipName,
sourceType: currentSession.sourceType, sourceType: currentSession.sourceType,
hiresKey: currentSession.hiresKey, hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey, // null for SRT/RTMP proxyKey: currentSession.proxyKey,
startedAt: currentSession.startedAt, startedAt: currentSession.startedAt,
stoppedAt, stoppedAt,
duration, duration,
}; };
} }
/**
* Get current capture status
* @returns {Object} Current state
*/
getStatus() { getStatus() {
if (!this.state.recording) { if (!this.state.recording) return { recording: false };
return {
recording: false,
};
}
const startTime = new Date(this.state.currentSession.startedAt); const startTime = new Date(this.state.currentSession.startedAt);
const now = new Date(); const now = new Date();
@ -330,13 +383,10 @@ class CaptureManager {
lastFrameAt, lastFrameAt,
msSinceFrame, msSinceFrame,
lastError: this.state.lastError, lastError: this.state.lastError,
codecs: this.state.currentSession.codecs,
}; };
} }
/**
* Format session response
* @private
*/
_formatSessionResponse() { _formatSessionResponse() {
const { currentSession, sessionId } = this.state; const { currentSession, sessionId } = this.state;
return { return {
@ -349,8 +399,10 @@ class CaptureManager {
hiresKey: currentSession.hiresKey, hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey, proxyKey: currentSession.proxyKey,
startedAt: currentSession.startedAt, startedAt: currentSession.startedAt,
codecs: currentSession.codecs,
}; };
} }
} }
export default new CaptureManager(); export default new CaptureManager();
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };