From 1074104d348623a63f1bd7d41b11d8b501e4d2ff Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Thu, 21 May 2026 21:17:31 +0000 Subject: [PATCH] fix(capture): FFmpeg 7.x DeckLink compatibility --- services/capture/src/capture-manager.js | 51 +++- services/capture/src/routes/capture.js | 8 +- services/capture/src/routes/capture.js.bak | 330 +++++++++++++++++++++ 3 files changed, 370 insertions(+), 19 deletions(-) create mode 100644 services/capture/src/routes/capture.js.bak diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index feee30f..d5f67ba 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -8,17 +8,17 @@ const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon'; // 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 }, + prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' }, + prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' }, + prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' }, + prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' }, + dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true, pixFmt: 'yuv422p' }, + dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' }, + dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' }, + h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' }, + h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' }, + h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' }, + hevc_nvenc: { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' }, }; const AUDIO_CODECS = { @@ -56,6 +56,7 @@ function buildEncodeArgs({ if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?'); args.push(...v.args); + if (v.pixFmt) args.push('-pix_fmt', v.pixFmt); if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate); if (framerate && framerate !== 'native') args.push('-r', framerate); @@ -63,7 +64,7 @@ function buildEncodeArgs({ if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate); if (audioChannels) args.push('-ac', String(audioChannels)); - if (isNetwork && (fmt === 'mov' || fmt === 'mp4')) { + if (fmt === 'mov' || fmt === 'mp4') { args.push('-movflags', '+frag_keyframe+empty_moov'); } args.push('-f', fmt); @@ -90,7 +91,7 @@ class CaptureManager { * Returns { inputArgs, isNetwork } * @private */ - _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) { + async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) { if (sourceType === 'srt') { let url; if (listen) { @@ -118,8 +119,28 @@ class CaptureManager { } // Default: SDI via DeckLink + // device may be an integer index (0-based) or a full device name string. + // FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo (2)'). + // Map integer index -> name using ffmpeg -sources decklink at runtime. + let deckLinkName = String(device); + if (typeof device === 'number' || /^\d+$/.test(String(device))) { + const idx = parseInt(device, 10); + try { + const { execSync } = await import('child_process'); + const out = execSync('ffmpeg -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 }); + const names = []; + for (const line of out.split('\n')) { + const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/); + if (m) names.push(m[1]); + } + if (names[idx]) deckLinkName = names[idx]; + else deckLinkName = `DeckLink Duo (${idx + 1})`; + } catch (_) { + deckLinkName = `DeckLink Duo (${parseInt(device, 10) + 1})`; + } + } return { - inputArgs: ['-f', 'decklink', '-i', String(device)], + inputArgs: ['-f', 'decklink', '-i', deckLinkName], isNetwork: false, }; } @@ -178,7 +199,7 @@ class CaptureManager { const startedAt = new Date().toISOString(); - const { inputArgs, isNetwork } = this._buildInputArgs({ + const { inputArgs, isNetwork } = await this._buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey, }); diff --git a/services/capture/src/routes/capture.js b/services/capture/src/routes/capture.js index 78ccae4..5c1a5e0 100644 --- a/services/capture/src/routes/capture.js +++ b/services/capture/src/routes/capture.js @@ -87,7 +87,7 @@ router.get('/devices', (req, res) => { let output = ''; try { - output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', { + output = execSync('ffmpeg -sources decklink 2>&1', { encoding: 'utf-8', }); } catch (error) { @@ -101,7 +101,7 @@ router.get('/devices', (req, res) => { let deviceIndex = 0; for (const line of lines) { - const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/); + const match = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/); if (match) { devices.push({ index: deviceIndex, @@ -137,10 +137,10 @@ router.post('/probe', async (req, res) => { if (source_type === 'sdi') { try { - const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 }); + const raw = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 }); const devices = []; for (const line of raw.split('\n')) { - const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/); + const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/); if (m) devices.push(m[1]); } return res.json({ ok: true, source_type, devices }); diff --git a/services/capture/src/routes/capture.js.bak b/services/capture/src/routes/capture.js.bak new file mode 100644 index 0000000..78ccae4 --- /dev/null +++ b/services/capture/src/routes/capture.js.bak @@ -0,0 +1,330 @@ +import express from 'express'; +import { execSync, spawn } from 'child_process'; +import captureManager from '../capture-manager.js'; + +import dgram from 'dgram'; +import net from 'net'; + +function parseUrl(u) { + try { + const m = String(u).match(/^[a-z]+:\/\/([^:\/?#]+)(?::(\d+))?/i); + if (!m) return null; + return { host: m[1], port: parseInt(m[2] || '0', 10) }; + } catch (_) { return null; } +} + +async function checkReachable(host, port, sourceType) { + if (!port) return { ok: true }; + if (sourceType === 'srt') return await udpSendProbe(host, port); + if (sourceType === 'rtmp') return await tcpConnectProbe(host, port); + return { ok: true }; +} + +function udpSendProbe(host, port) { + return new Promise((resolve) => { + const sock = dgram.createSocket('udp4'); + let done = false; + const finish = (result) => { if (done) return; done = true; try { sock.close(); } catch (_) {} resolve(result); }; + sock.on('error', (err) => { + const msg = String(err && err.message || err); + if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) { + finish({ ok: false, error: 'Host ' + host + ' is unreachable from the capture container (no route). Confirm the IP is correct and the machine is online.', diagnostic: msg }); + } else if (/ECONNREFUSED|EPORTUNREACH/i.test(msg)) { + finish({ ok: false, error: 'Nothing is listening on UDP ' + host + ':' + port + '. In vMix, confirm the SRT output is Started and the port matches.', diagnostic: msg }); + } else { + finish({ ok: false, error: 'UDP probe failed: ' + msg, diagnostic: msg }); + } + }); + sock.send(Buffer.from('Z-AMPP-PROBE'), port, host, () => {}); + setTimeout(() => finish({ ok: true }), 1500); + }); +} + +function tcpConnectProbe(host, port) { + return new Promise((resolve) => { + const sock = new net.Socket(); + let done = false; + const finish = (r) => { if (done) return; done = true; try { sock.destroy(); } catch (_) {} resolve(r); }; + sock.setTimeout(2500); + sock.once('connect', () => finish({ ok: true })); + sock.once('timeout', () => finish({ ok: false, error: 'TCP connect to ' + host + ':' + port + ' timed out. Confirm the host is reachable and a TCP listener is running.' })); + sock.once('error', (err) => { + const msg = String(err && err.message || err); + if (/EHOSTUNREACH|ENETUNREACH/i.test(msg)) finish({ ok: false, error: 'Host ' + host + ' unreachable (no route).', diagnostic: msg }); + else if (/ECONNREFUSED/i.test(msg)) finish({ ok: false, error: 'Nothing is listening on TCP ' + host + ':' + port + '.', diagnostic: msg }); + else finish({ ok: false, error: 'TCP probe failed: ' + msg, diagnostic: msg }); + }); + sock.connect(port, host); + }); +} + +function classifyProbeError(raw, sourceType) { + const r = (raw || '').toLowerCase(); + if (sourceType === 'srt') { + if (/connection .* failed: (input\/output|timer expired|connection setup failure)/i.test(raw)) { + return 'SRT handshake failed. In vMix: confirm the External Output is Started, Type=SRT, Mode=Listener, port matches, and any passphrase / stream ID is empty (or copied exactly).'; + } + } + if (sourceType === 'rtmp') { + if (/connection refused/i.test(r)) return 'Nothing is listening on RTMP at this address. Start your RTMP source.'; + if (/end-of-file|invalid data found/i.test(r)) return 'Got a TCP connection but no RTMP stream. Confirm the source is publishing and the path / stream-key match.'; + } + return raw; +} + + +const router = express.Router(); + +const MAM_API_URL = process.env.MAM_API_URL || 'http://mam-api:3000'; + +/** + * GET /devices + * List available DeckLink devices + */ +router.get('/devices', (req, res) => { + try { + const devices = []; + let output = ''; + + try { + output = execSync('ffmpeg -f decklink -list_devices 1 -i dummy 2>&1', { + encoding: 'utf-8', + }); + } catch (error) { + // ffmpeg returns non-zero, but stderr is still captured + output = error.stderr ? error.stderr.toString() : error.toString(); + } + + // Parse ffmpeg output for DeckLink device names + // Format: [decklink @ ...] "DeckLink Quad 2" (input #0) + const lines = output.split('\n'); + let deviceIndex = 0; + + for (const line of lines) { + const match = line.match(/^\s*\[decklink[^\]]*\]\s+"([^"]+)"/); + if (match) { + devices.push({ + index: deviceIndex, + name: match[1], + }); + deviceIndex++; + } + } + + res.json({ devices }); + } catch (error) { + console.error('Error listing devices:', error); + res.status(500).json({ error: 'Failed to list devices' }); + } +}); + +/** + * GET /status + * Get current capture status + */ +router.get('/status', (req, res) => { + try { + const status = captureManager.getStatus(); + res.json(status); + } catch (error) { + console.error('Error getting status:', error); + res.status(500).json({ error: 'Failed to get status' }); + } +}); +router.post('/probe', async (req, res) => { + try { + const { source_type = 'sdi', source_url, listen = false } = req.body || {}; + + if (source_type === 'sdi') { + try { + const raw = execSync('ffmpeg -hide_banner -f decklink -list_devices 1 -i dummy 2>&1', { encoding: 'utf-8', timeout: 5000 }); + const devices = []; + for (const line of raw.split('\n')) { + const m = line.match(/\[decklink[^\]]*\]\s+"([^"]+)"/); + if (m) devices.push(m[1]); + } + return res.json({ ok: true, source_type, devices }); + } catch (err) { + const out = (err.stderr || err.stdout || err.toString()).toString(); + return res.json({ ok: false, source_type, error: out.slice(0, 800) }); + } + } + + if (listen) { + return res.json({ ok: false, source_type, error: 'Listener-mode sources cannot be probed standalone. Start the recorder and watch the signal indicator.' }); + } + + if (!source_url) return res.status(400).json({ error: 'source_url is required' }); + + // Pre-flight: parse host:port and check L3/L4 reachability so we can give + // an actionable error instead of the opaque libsrt "Input/output error". + const parsed = parseUrl(source_url); + if (!parsed) { + return res.json({ ok: false, source_type, source_url, error: 'Could not parse host:port from URL.' }); + } + const reach = await checkReachable(parsed.host, parsed.port, source_type); + if (!reach.ok) { + return res.json({ ok: false, source_type, source_url, error: reach.error, diagnostic: reach.diagnostic }); + } + + let url = source_url; + if (source_type === 'srt' && !/mode=/.test(url)) { + url += (url.includes('?') ? '&' : '?') + 'mode=caller'; + } + + const args = ['-hide_banner','-v','error','-probesize','32M','-analyzeduration','8M','-rw_timeout','7000000','-i', url, '-show_streams','-show_format','-of','json']; + const ff = spawn('ffprobe', args); + let stdout = '', stderr = ''; + ff.stdout.on('data', (c) => { stdout += c; }); + ff.stderr.on('data', (c) => { stderr += c; }); + const killer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 10000); + ff.on('close', (code) => { + clearTimeout(killer); + if (code !== 0) { + const rawErr = (stderr || 'ffprobe failed').slice(0, 800); + const friendly = classifyProbeError(rawErr, source_type); + return res.json({ ok: false, source_type, source_url, error: friendly, diagnostic: rawErr }); + } + try { + const parsed = JSON.parse(stdout); + const streams = (parsed.streams || []).map(s => ({ + index: s.index, codec_type: s.codec_type, codec_name: s.codec_name, + width: s.width, height: s.height, pix_fmt: s.pix_fmt, + r_frame_rate: s.r_frame_rate, avg_frame_rate: s.avg_frame_rate, + sample_rate: s.sample_rate, channels: s.channels, + channel_layout: s.channel_layout, bit_rate: s.bit_rate, + })); + return res.json({ ok: true, source_type, source_url, + format: { format_name: parsed.format && parsed.format.format_name, duration: parsed.format && parsed.format.duration, bit_rate: parsed.format && parsed.format.bit_rate }, + streams }); + } catch (err) { + return res.json({ ok: false, source_type, source_url, error: 'Could not parse ffprobe output: ' + err.message }); + } + }); + } catch (error) { + console.error('Probe error:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /start + * Start a new capture session + * + * Body (SDI): + * { project_id, clip_name, device, bin_id?, source_type? } + * + * Body (SRT/RTMP caller): + * { project_id, clip_name, source_type, source_url, bin_id? } + * + * Body (SRT/RTMP listener): + * { project_id, clip_name, source_type, listen: true, listen_port?, stream_key?, bin_id? } + */ +router.post('/start', async (req, res) => { + try { + const { + project_id, + bin_id, + clip_name, + device, + source_type = 'sdi', + source_url, + listen = false, + listen_port, + stream_key, + } = req.body; + + if (!project_id || !clip_name) { + return res.status(400).json({ + error: 'Missing required fields: project_id, clip_name', + }); + } + + // Source-specific validation + if (source_type === 'sdi') { + if (device === undefined || device === null) { + return res.status(400).json({ error: 'SDI source requires: device' }); + } + } else if (source_type === 'srt' || source_type === 'rtmp') { + if (!listen && !source_url) { + return res.status(400).json({ + error: `${source_type.toUpperCase()} caller mode requires: source_url`, + }); + } + } else { + return res.status(400).json({ + error: `Unknown source_type: ${source_type}. Must be sdi, srt, or rtmp`, + }); + } + + const session = await captureManager.start({ + projectId: project_id, + binId: bin_id || null, + clipName: clip_name, + device, + sourceType: source_type, + sourceUrl: source_url, + listen, + listenPort: listen_port, + streamKey: stream_key, + }); + + res.json(session); + } catch (error) { + console.error('Error starting capture:', error); + res.status(500).json({ error: error.message }); + } +}); + +/** + * POST /stop + * Stop the current capture session + * Body: { session_id } + */ +router.post('/stop', async (req, res) => { + try { + const { session_id } = req.body; + + if (!session_id) { + return res.status(400).json({ error: 'Missing required field: session_id' }); + } + + const completedSession = await captureManager.stop(session_id); + + // Register asset with mam-api. + // If proxyKey is null (SRT/RTMP source), set needsProxy=true so the + // worker generates a proxy from the hires file asynchronously. + try { + const mamResponse = await fetch(`${MAM_API_URL}/api/v1/assets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + projectId: completedSession.projectId, + binId: completedSession.binId, + clipName: completedSession.clipName, + sourceType: completedSession.sourceType, + hiresKey: completedSession.hiresKey, + proxyKey: completedSession.proxyKey, + needsProxy: completedSession.proxyKey === null, + duration: completedSession.duration, + capturedAt: completedSession.startedAt, + }), + }); + + if (!mamResponse.ok) { + console.warn( + `MAM API registration returned ${mamResponse.status}: ${await mamResponse.text()}`, + ); + } + } catch (mamError) { + console.warn('Failed to register asset with MAM API:', mamError.message); + } + + res.json(completedSession); + } catch (error) { + console.error('Error stopping capture:', error); + res.status(500).json({ error: error.message }); + } +}); + +export default router;