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';
// ── 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 };