From eba8e948872ee2ecc847739438ed6ce998f772ca Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Mon, 1 Jun 2026 23:09:21 +0000 Subject: [PATCH] fix(capture-manager): readFirstStderrLine skips non-JSON bridge log lines The flock-based board serialization in deltacast-bridge emits [board] log lines to stderr before the JSON format line. readFirstStderrLine was failing on the first non-JSON line. Now loops over complete lines, skips any not starting with {, and waits for the actual JSON. --- services/capture/src/capture-manager.js | 1275 ++++++++++++++++++++++- 1 file changed, 1257 insertions(+), 18 deletions(-) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 7ba6632..b663834 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -5,10 +5,10 @@ import { v4 as uuidv4 } from 'uuid'; import { createUploadStream } from './s3/client.js'; /** - * Reads stderr lines from a spawned process until it finds a JSON line. - * Non-JSON lines (e.g. [board] log messages from the bridge) are logged - * and skipped. Resolves with the parsed JSON object when a JSON line arrives. - * Rejects if the process exits before emitting JSON, or if timeoutMs elapses. + * Reads the first line from a spawned process's stderr stream. + * Resolves with the parsed JSON object when the first '\n' arrives. + * Rejects if the process exits with a non-zero code before emitting a line, + * or if timeoutMs elapses. */ function readFirstStderrLine(proc, timeoutMs = 35_000) { return new Promise((resolve, reject) => { @@ -24,27 +24,17 @@ function readFirstStderrLine(proc, timeoutMs = 35_000) { proc.stderr.on('data', (chunk) => { buf += chunk; let nl; - // Process all complete lines in the buffer while ((nl = buf.indexOf('\n')) !== -1) { const line = buf.slice(0, nl).trim(); buf = buf.slice(nl + 1); if (!line) continue; - // Skip non-JSON log lines emitted by the bridge (e.g. "[board] waiting...") - if (!line.startsWith('{')) { - console.error(`[deltacast-bridge] ${line}`); - continue; - } + if (!line.startsWith('{')) { console.error('[deltacast-bridge] ' + line); continue; } clearTimeout(timer); try { const parsed = JSON.parse(line); - if (parsed.error) { - settle(() => reject(new Error(`deltacast-capture: ${parsed.error}`))); - } else { - settle(() => resolve(parsed)); - } - } catch (e) { - settle(() => reject(new Error(`deltacast-capture: invalid JSON on stderr: ${line}`))); - } + if (parsed.error) { settle(() => reject(new Error('deltacast-capture: ' + parsed.error))); } + else { settle(() => resolve(parsed)); } + } catch (e) { settle(() => reject(new Error('deltacast-capture: invalid JSON: ' + line))); } return; } }); @@ -55,3 +45,1252 @@ function readFirstStderrLine(proc, timeoutMs = 35_000) { }); }); } + +const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon'; + +// Growing-files mode: writes the master to a local SMB-backed share that the +// editor can mount, instead of streaming to S3 in real time. The promotion +// worker uploads the finalized file to S3 after the recording stops. +// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container +// (see routes/recorders.js where the env is composed). +const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true'; +const GROWING_PATH = process.env.GROWING_PATH || '/growing'; + +// Approach A: when a CIFS source is supplied, this (privileged) container mounts +// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied +// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount +// (the host-bound /growing volume is used instead, or S3 streaming if growing +// is off). +// mount.cifs needs a UNC source (//host/share). Operators (and Settings) often +// store the share as an `smb://host/share` URL or a Windows `\\host\share` +// path; the kernel rejects those outright ("Mounting cifs URL not implemented +// yet"), which silently drops us back to S3. Normalize any of these forms to +// the `//host/share` UNC the mount helper accepts. +function toUncShare(raw) { + if (!raw) return ''; + let s = String(raw).trim().replace(/\\/g, '/'); // \\host\share -> //host/share + s = s.replace(/^smb:\/\//i, '//'); // smb://host/share -> //host/share + if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share + return s; +} +const GROWING_SMB_MOUNT = toUncShare(process.env.GROWING_SMB_MOUNT || ''); +const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || ''; +const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || ''; +const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0'; +const SMB_CREDS_FILE = '/run/smb-creds'; + +// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it +// mounted, or a host bind-mount is present). +function isMounted(path) { + try { execFileSync('mountpoint', ['-q', path]); return true; } + catch { return false; } +} + +// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only +// file (NOT the command line) so they never appear in `ps`/process listings. +// Returns true on success (or if already mounted), false on failure — callers +// fall back to S3 streaming so a recording is never lost. +function mountGrowingShare() { + if (!GROWING_SMB_MOUNT) return false; + try { + if (isMounted(GROWING_PATH)) { + console.log('[capture] growing share already mounted at', GROWING_PATH); + return true; + } + try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {} + writeFileSync( + SMB_CREDS_FILE, + `username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`, + { mode: 0o600 } + ); + const opts = [ + `credentials=${SMB_CREDS_FILE}`, + 'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775', + `vers=${GROWING_SMB_VERS}`, + ].join(','); + execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts], + { stdio: ['ignore', 'ignore', 'pipe'] }); + console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH); + return true; + } catch (err) { + const stderr = err.stderr ? err.stderr.toString().trim() : err.message; + console.error('[capture] CIFS mount failed (falling back to S3 streaming):', stderr); + return false; + } +} + +// Best-effort unmount on session stop. Ignores "not mounted". +function unmountGrowingShare() { + if (!GROWING_SMB_MOUNT) return; + try { + if (isMounted(GROWING_PATH)) { + execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] }); + console.log('[capture] unmounted growing share at', GROWING_PATH); + } + } catch (err) { + const stderr = err.stderr ? err.stderr.toString().trim() : err.message; + console.warn('[capture] growing share unmount failed (ignored):', stderr); + } +} + +// ── 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, 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' }, + // All-Intra HEVC on NVENC — the growing-file master codec. + // Goal: every frame an IDR (all-intra), so a still-growing file is decodable + // to its last complete frame — the prerequisite for edit-while-record. + // + // NVENC will NOT accept `-g 1`: InitializeEncoder enforces + // "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1 + // is rejected with EINVAL (validated on the L4, driver 595). The working + // recipe for true all-intra is therefore: + // -bf 0 no B-frames + // -g 600 large GOP just to satisfy the init check + // -forced-idr 1 forced keyframes are emitted as IDR + // -force_key_frames expr:1 force a keyframe on EVERY frame + // → ffprobe confirms pict_type = I for all frames. + // + // Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof), + // NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted"). + // The frag-MOV index is not deferred to EOF, so the file stays readable while + // growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.) + // + // -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get + // to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU). + hevc_nvenc: { + args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'], + bitrateControl: true, + pixFmt: 'p010le', + }, +}; + +// nvenc codecs available in the capture image. Used both to validate the master +// codec and (issue #164) as the GPU-availability signal for the HLS preview. +const NVENC_CODECS = new Set(['h264_nvenc', 'hevc_nvenc']); + +// ── GPU availability for this sidecar (issue #164) ─────────────────────── +// The HLS monitor preview should be GPU-encoded (h264_nvenc) when — and only +// when — the GPU is actually attached to this capture container. A non-GPU +// recorder must keep using libx264, otherwise ffmpeg would fail to open the +// nvenc encoder and break the preview. +// +// Two signals, OR'd for robustness: +// 1) The master video codec is an nvenc codec. recorders.js derives `useGpu` +// from exactly this (GPU_CODECS = [hevc_nvenc, h264_nvenc]) and node-agent +// only attaches the NVIDIA runtime when useGpu is set — so an nvenc master +// codec is a reliable proxy for "this sidecar has the GPU". +// 2) node-agent injects NVIDIA_VISIBLE_DEVICES into the sidecar env whenever +// useGpu is set. This is the most direct in-process evidence the runtime +// attached a GPU, and covers the (currently unused) case where the GPU is +// present but the master codec is a CPU codec. +function gpuAvailableForPreview(masterCodec) { + if (NVENC_CODECS.has(masterCodec)) return true; + const vis = process.env.NVIDIA_VISIBLE_DEVICES; + if (vis && vis !== 'void' && vis !== 'none') return true; + return false; +} + +// Build the HLS preview video-encode args. `segTime` is the HLS segment length +// (seconds); we pin the GOP/keyframe interval to one IDR per segment so every +// segment starts on a keyframe (misaligned keyframes were the root cause of the +// playout preview black/flashing bug — keep the preview robust). +function buildHlsVideoArgs(masterCodec, framerate) { + // Frames-per-segment for keyframe alignment. The SDI preview runs at the + // capture framerate; default to 30 (matches the test-card rate) when unknown. + const fps = Number.parseFloat(framerate) || 30; + const segTime = 2; // matches -hls_time below + const gop = Math.max(1, Math.round(fps * segTime)); + if (gpuAvailableForPreview(masterCodec)) { + // Low-latency NVENC preset (p1 + ll tune). forced-idr + a keyframe every GOP + // frames keeps segment boundaries on IDR frames so hls.js can sync cleanly. + return [ + '-c:v', 'h264_nvenc', '-preset', 'p1', '-tune', 'll', + '-pix_fmt', 'yuv420p', '-b:v', '2M', + '-g', String(gop), '-forced-idr', '1', '-sc_threshold', '0', + ]; + } + // No GPU → keep the original CPU encode (must not break a non-GPU recorder). + return [ + '-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency', + '-pix_fmt', 'yuv420p', '-b:v', '2M', + '-g', String(gop), '-sc_threshold', '0', + ]; +} + +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', +}; + +// Growing-file (edit-while-record) master format — MXF OP1a / XDCAM HD422, +// written by bmx (raw2bmx), NOT by ffmpeg's MXF muxer. +// +// This is the SIXTH iteration. The five prior attempts and WHY they failed +// (root-caused with authoritative sources + live structural analysis on the +// zampp2 capture image): +// +// 1) Fragmented MP4/MOV (+frag_keyframe+empty_moov): Premiere's QuickTime +// importer needs the classic stco/stsz/stts sample tables in one top-level +// moov; a fragmented MOV never has them while growing → "unable to open". +// +// 2) MXF OP1a / DNxHR HQ via ffmpeg: a DNxHR MXF SIGKILLed mid-write has ZERO +// body partitions and probes duration=N/A — DNxHR's large VBR frames don't +// trigger ffmpeg's per-partition flush, so only the header is on disk. +// +// 3) MPEG-TS H.264 High 4:2:2: Premiere's H.264 importer only accepts 8-bit +// 4:2:0. +// +// 4) MPEG-TS H.264 High 4:2:0 all-intra + AAC: STILL "unsupported compression +// type" — Premiere does not treat a raw .ts elementary stream as a clean +// importable growing clip. +// +// 5) MXF OP1a / XDCAM HD422 (MPEG-2 422) via ffmpeg's `-f mxf` muxer: this was +// believed to flush incremental body partitions, but PROVEN unable to +// produce a TRUE growing file — ffmpeg's MXF muxer writes the real +// duration/index only in the FOOTER at av_write_trailer (close). A +// metadata-only probe of the mid-write file reports duration=N/A right up +// until the writer exits, so Premiere's growing-file refresh never sees the +// file extend. (Same muxer that defers the index to EOF.) +// +// FIX — MXF OP1a carrying XDCAM HD422 (MPEG-2 422 @ 50 Mbps-class) + PCM, muxed +// by bmx/raw2bmx (the reference growing-OP1a writer, used by BBC/broadcast): +// +// WHY raw2bmx (the key discovery, PROVEN live on zampp2): +// * raw2bmx with `-t op1a --part ` writes a NEW body partition PLUS +// a NEW IndexTableSegment (carrying an updated IndexDuration) at the +// interval. The recorded duration is therefore readable — and INCREASES — +// from the header+index ALONE while the file is still being written, no +// footer needed. Verified by snapshotting the growing file mid-write and +// parsing the IndexTableSegment IndexDuration (local tag 0x3F0C): +// T= 3s: 7 partitions, max IndexDuration = 43 frames +// T= 8s: 17 partitions, max IndexDuration = 193 frames +// T=15s: 31 partitions, max IndexDuration = 403 frames +// The recorded frame count grows monotonically, lagging the record head by +// ~one partition interval — exactly the editable-head behaviour Premiere's +// growing-MXF reader consumes. A mid-write snapshot also decodes cleanly +// (mpeg2video 1920x1080 + 2×PCM, ffmpeg decode exit 0). Contrast with the +// ffmpeg `-f mxf` path (attempt #5): duration=N/A until close. +// * Adobe OFFICIALLY recommends MXF for growing-file workflows; XDCAM HD422 +// (MPEG-2 422 in MXF OP1a) + PCM is read by Premiere's built-in MXF reader +// with no plugin and is the broadcast-standard growing acquisition format. +// +// Pipeline (single SDI read — DeckLink cannot be opened twice): +// ffmpeg decklink → yadif → split → +// (a) MPEG-2 422 elementary VIDEO → named FIFO ┐ +// (b) PCM s16le AUDIO → named FIFO ├→ raw2bmx -t op1a +// (c) H.264 HLS preview (unchanged, keeps monitor live) +// raw2bmx reads the two essence FIFOs and writes the growing OP1a MXF to the +// CIFS share. On stop, ffmpeg is stopped cleanly so raw2bmx gets EOF and +// finalizes the footer; we await raw2bmx exit before reporting complete. +// +// Audio: PCM s16le — the native, broadcast-standard MXF audio mapping +// Premiere's MXF reader expects (NOT AAC). +// +// HONEST CAVEAT (cannot be verified without real Premiere on the workstation): +// the growing IndexDuration / body-partition structure is PROVEN above and +// matches Adobe's documented growing-MXF requirement — but only the user +// opening the growing .mxf in actual Premiere Pro (with "Automatically refresh +// growing files" enabled in Preferences > Media) can confirm the end-to-end +// edit-while-record. +// +// ── ffmpeg elementary-essence args (input to the FIFOs) ─────────────────── +// (a) MPEG-2 422, 8-bit 4:2:2 (Premiere-native XDCAM HD422). `-dc 10` + the CBR +// bitrate (operator target, default 50 Mbps) match XDCAM HD422 essence. `-g 15` +// keeps a short GOP. Muxed to a raw `mpeg2video` elementary stream (no +// container) so raw2bmx ingests it via --mpeg2lg_*. +const GROWING_VIDEO_ELEMENTARY_ARGS = [ + '-c:v', 'mpeg2video', '-pix_fmt', 'yuv422p', + '-dc', '10', '-g', '15', '-bf', '2', +]; +const GROWING_DEFAULT_BITRATE = '25M'; +const GROWING_EXT = 'mxf'; +// Video essence partition interval (frames). raw2bmx starts a new body partition +// + IndexTableSegment every PART_INTERVAL frames; this is the granularity at +// which the growing file's recorded duration advances. ~1s at 25/29.97 fps. +const GROWING_PART_INTERVAL_FRAMES = 30; + +// Map the recorder's resolution/fps to (1) the raw2bmx MPEG-2 Long GOP essence +// input flag and (2) the ffmpeg edit-rate (`-r`). raw2bmx needs the correct +// raster flag so the essence is wrapped as the right XDCAM HD422 variant; an +// 1080i59.94 default is used when the recorder fields are absent (the most +// common SDI broadcast raster). Returns: +// { rawFlag, frameRate, ffRate } +// where rawFlag is e.g. '--mpeg2lg_422p_hl_1080i', frameRate is the raw2bmx +// `-f` value (e.g. '30000/1001'), and ffRate is the ffmpeg `-r` value. +// +// NOTE: the exact interlaced-vs-progressive raster and the fps for a real +// DeckLink SDI feed can only be confirmed against the live signal. This derives +// a sensible value from the recorder's configured resolution/framerate; if those +// are absent or ambiguous it defaults to 1080i59.94. A live DeckLink confirm of +// the actual SDI raster/fps is advised before production use (see report). +function deriveGrowingRaster(resolution, framerate) { + // Normalise fps. Accept '59.94', '60000/1001', '25', '50', '30', '29.97'… + let fpsNum = null; + const fr = (framerate == null) ? '' : String(framerate).trim(); + if (/^\d+\/\d+$/.test(fr)) { + const [n, d] = fr.split('/').map(Number); + if (d) fpsNum = n / d; + } else if (fr && fr !== 'native') { + const f = Number.parseFloat(fr); + if (Number.isFinite(f)) fpsNum = f; + } + + // Resolution → height + scan. Accept '1920x1080', '1080i', '1080p', '720p', + // '720', '576i', etc. + const res = (resolution == null) ? '' : String(resolution).trim().toLowerCase(); + let height = null; + let scan = null; // 'i' | 'p' | null + const mDim = res.match(/(\d{3,4})x(\d{3,4})/); + if (mDim) height = parseInt(mDim[2], 10); + const mH = res.match(/(\d{3,4})\s*([ip])/); + if (mH) { height = parseInt(mH[1], 10); scan = mH[2]; } + if (height == null) { + const only = res.match(/\b(2160|1080|720|576|480)\b/); + if (only) height = parseInt(only[1], 10); + } + if (height == null) height = 1080; // default raster + + // ffmpeg rate + raw2bmx rate strings for the common broadcast rates. + function rates(fps) { + if (fps == null) return { ff: '30000/1001', raw: '30000/1001' }; // 1080i59.94 default + if (Math.abs(fps - 59.94) < 0.2 || Math.abs(fps - 29.97) < 0.05) + return { ff: '30000/1001', raw: '30000/1001' }; + if (Math.abs(fps - 60) < 0.05) return { ff: '60', raw: '60' }; + if (Math.abs(fps - 50) < 0.05) return { ff: '25', raw: '25' }; // 1080i50 → 25 fps frames + if (Math.abs(fps - 25) < 0.05) return { ff: '25', raw: '25' }; + if (Math.abs(fps - 24) < 0.2) return { ff: '24000/1001', raw: '24000/1001' }; + if (Math.abs(fps - 30) < 0.05) return { ff: '30', raw: '30' }; + return { ff: String(fps), raw: String(fps) }; + } + + // Default scan: 1080 → interlaced (broadcast SDI default), 720/below → p. + if (scan == null) scan = (height >= 1080) ? 'i' : 'p'; + const r = rates(fpsNum); + + let rawFlag; + if (height >= 1080) { + rawFlag = (scan === 'p') ? '--mpeg2lg_422p_hl_1080p' : '--mpeg2lg_422p_hl_1080i'; + } else if (height >= 720) { + rawFlag = '--mpeg2lg_422p_hl_720p'; // 720 is always progressive + if (fpsNum == null) { r.ff = '60000/1001'; r.raw = '60000/1001'; } + } else { + rawFlag = '--mpeg2lg_422p_ml_576i'; // SD 576i (PAL); 25 fps + r.ff = '25'; r.raw = '25'; + } + + return { rawFlag, frameRate: r.raw, ffRate: r.ff }; +} + +// ── Source-backend abstraction (issue #168) ────────────────────────────── +// The capture input was historically hard-wired to a single `-f decklink -i …` +// construction. To allow other SDI capture cards (Deltacast, AJA) to be added +// later without touching the encode/output/HLS pipeline, the per-backend FFmpeg +// INPUT-arg construction now lives behind this map. Each backend exposes: +// +// buildInput(ctx) -> { inputArgs, isNetwork } (may be async) +// +// where `ctx` carries the resolved recorder fields the backend needs (device). +// The rest of capture-manager consumes the returned `inputArgs` unchanged, so +// adding a backend is purely additive. +// +// IMPORTANT: `blackmagic` is a behaviour-preserving extraction of the previous +// default DeckLink path — for an existing DeckLink recorder the produced ffmpeg +// input args are byte-for-byte identical to the pre-refactor code. The +// `deltacast`/`aja` entries are stubs that throw until the hardware/SDK plumbing +// lands. +const sourceBackends = { + // BlackMagic DeckLink over SDI (the only backend implemented today). + // 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 (2)'). + // Map integer index -> name using ffmpeg -sources decklink at runtime. + // + // ffmpeg -sources decklink output format: + // Auto-detected sources for decklink: + // DeckLink Duo 2 + // DeckLink Duo 2 (2) + // Lines containing device names start with whitespace; the header line + // starts with a non-space character. Previous code used a v4l2-style + // hex-address regex that never matched DeckLink output → index 1+ always + // fell through to a wrong fallback, producing black output from port 2+. + blackmagic: { + async buildInput({ device }) { + 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 -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 }); + const names = []; + for (const line of out.split('\n')) { + // DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)" + const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/); + if (m) names.push(m[1]); + } + if (names[idx]) { + deckLinkName = names[idx]; + console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`); + } else { + // Fallback: cannot determine model name without enumeration. + // Log a warning — operator should check the detected device list. + console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`); + deckLinkName = `DeckLink (${idx})`; + } + } catch (err) { + console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`); + // Pass the numeric index directly; some ffmpeg builds accept it. + deckLinkName = String(device); + } + } + return { + inputArgs: ['-f', 'decklink', '-i', deckLinkName], + isNetwork: false, + }; + }, + }, + + // Stubs — hardware/SDK plumbing not yet implemented. These throw clearly so a + // misconfigured recorder fails fast instead of silently falling back to the + // wrong card. + deltacast: { + buildInput() { + throw new Error('deltacast backend not yet implemented — requires hardware'); + }, + }, + aja: { + buildInput() { + throw new Error('aja backend not yet implemented — requires hardware'); + }, + }, +}; + +function buildEncodeArgs({ + codec, videoBitrate, framerate, + audioCodec, audioBitrate, audioChannels, + container, isNetwork, isProxy = false, + growing = false, +}) { + // NOTE: the growing master is NOT muxed by ffmpeg any more — raw2bmx writes + // the growing OP1a MXF from elementary essence FIFOs (see start()). The + // growing ffmpeg command (elementary MPEG-2 422 video + PCM audio to FIFOs, + // plus the HLS preview) is constructed directly in start(), so buildEncodeArgs + // is no longer called with growing=true. The `growing` param is retained for + // call-site compatibility; if ever set, fall through to the finalized path so + // we never silently produce a wrong file. + + 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.pixFmt) args.push('-pix_fmt', v.pixFmt); + 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)); + + // moov-atom placement is the difference between a Premiere-openable master and + // a "file cannot be opened" error. + // + // Finalized masters (the S3-piped recording that stops cleanly) must NOT be + // fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic + // stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV + // (moof/trun, empty sample tables) makes Premiere report "file cannot be + // opened." We write a clean, non-fragmented MOV instead. `+faststart` puts the + // moov before mdat on the second pass so the file is instantly + // seekable/streamable too. + if (fmt === 'mov' || fmt === 'mp4') { + args.push('-movflags', '+faststart'); + } + // ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag. + args.push('-f', fmt); + + return args; +} + +class CaptureManager { + constructor() { + this.state = { + recording: false, + sessionId: null, + processes: {}, + currentSession: {}, + framesReceived: 0, + currentFps: 0, + lastFrameAt: null, + lastError: null, + }; + } + + /** + * Build FFmpeg input arguments based on source type. + * Returns { inputArgs, isNetwork } + * @private + */ + async _buildInputArgs({ sourceType, sourceBackend = 'blackmagic', device, port, board, sourceUrl, listen, listenPort, streamKey }) { + if (sourceType === 'srt') { + let url; + if (listen) { + const port = listenPort || 9000; + url = `srt://0.0.0.0:${port}?mode=listener`; + } else { + url = sourceUrl; + if (!url.includes('mode=')) { + url += (url.includes('?') ? '&' : '?') + 'mode=caller'; + } + } + return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true }; + } + + if (sourceType === 'rtmp') { + if (listen) { + const port = listenPort || 1935; + const key = streamKey || 'stream'; + return { + inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`], + isNetwork: true, + }; + } + return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true }; + } + + // Deltacast SDI via VideoMaster SDK FFmpeg plugin. + if (sourceType === 'deltacast') { + const idx = (typeof device === 'number' || /^\d+$/.test(String(device))) + ? parseInt(device, 10) : 0; + const audioFifo = `/tmp/dc-audio-${this._sessionIdForBridge}`; + + const { execFileSync: _execFile } = await import('child_process'); + const { unlinkSync: _unlink, existsSync: _exists } = await import('node:fs'); + if (_exists(audioFifo)) { try { _unlink(audioFifo); } catch (_) {} } + try { _execFile('mkfifo', [audioFifo]); } catch (e) { + throw new Error(`Failed to create audio FIFO ${audioFifo}: ${e.message}`); + } + + // ONE board (index 0) carries 8 channels (ports 0-7). --device is the + // board index, --port is the selected channel. board defaults to 0; the + // capture channel comes from source_config.port, falling back to the + // legacy device index so existing single-value recorders keep working. + const boardIdx = (typeof board === 'number' || /^\d+$/.test(String(board))) + ? parseInt(board, 10) : 0; + const portIdx = (typeof port === 'number' || /^\d+$/.test(String(port))) + ? parseInt(port, 10) : idx; + const bridge = spawn('deltacast-capture', [ + '--device', String(boardIdx), + '--port', String(portIdx), + '--audio-pipe', audioFifo, + '--signal-timeout', '30', + ], { stdio: ['ignore', 'pipe', 'pipe'] }); + + const fmt = await readFirstStderrLine(bridge, 35_000); + bridge.stderr.on('data', (d) => console.error(`[deltacast-bridge] ${d.toString().trimEnd()}`)); + + return { + inputArgs: [ + '-f', 'rawvideo', + '-pix_fmt', fmt.pix_fmt, + '-video_size', `${fmt.width}x${fmt.height}`, + '-framerate', `${fmt.fps_num}/${fmt.fps_den}`, + '-i', 'pipe:0', + '-f', 's16le', + '-ar', String(fmt.audio_rate), + '-ac', String(fmt.audio_channels), + '-i', audioFifo, + ], + isNetwork: false, + bridgeProcess: bridge, + audioFifo, + interlaced: !!fmt.interlaced, + }; + } + + // Default: SDI via a pluggable source backend (issue #168). The backend + // selection defaults to `blackmagic` (DeckLink) so existing SDI recorders + // behave exactly as before. Deltacast/AJA backends throw until their + // hardware/SDK plumbing lands. + const backend = sourceBackends[sourceBackend]; + if (!backend) { + throw new Error(`Unknown source backend "${sourceBackend}" — expected one of: ${Object.keys(sourceBackends).join(', ')}`); + } + return await backend.buildInput({ device }); + } + + /** + * Build the bash orchestrator command for the GROWING master (raw2bmx). + * + * One ffmpeg reads the source once (DeckLink can't be opened twice) and writes + * THREE outputs: + * (a) MPEG-2 422 elementary VIDEO → video FIFO ─┐ raw2bmx -t op1a reads + * (b) PCM s16le AUDIO → audio FIFO ─┘ these and writes the + * growing OP1a MXF. + * (c) H.264 HLS preview (unchanged) — keeps the UI monitor live. + * + * FIFO orchestration (the tricky part — proven on the live capture node): + * raw2bmx opens its inputs lazily (video first, reads the header, THEN opens + * audio), while ffmpeg opens ALL its outputs up-front and blocks on the + * audio FIFO until a reader appears → classic open-order deadlock. We break + * it by having the parent shell PRIME both FIFOs read-write (non-blocking + * open) so neither child blocks on open. CRUCIAL: the children must NOT + * inherit a priming *writer* (it would keep the FIFO open and starve raw2bmx + * of EOF forever), so each child closes the priming FDs before exec. The + * parent holds the priming FDs (as a reader/writer) only until raw2bmx has + * opened BOTH FIFOs, then drops them — leaving ffmpeg as the SOLE writer, so + * when ffmpeg exits raw2bmx gets a clean EOF and finalizes the MXF footer. + * + * Stop/finalize: the orchestrator traps SIGINT/SIGTERM and forwards SIGINT to + * ffmpeg (clean stop → EOF to raw2bmx), then `wait`s for raw2bmx and exits + * with raw2bmx's status. The Node side spawns this with detached:true and, on + * stop(), signals it and AWAITS its exit — so the finalized, valid MXF is on + * the share before the promotion worker uploads it. + * + * Returns the argv for spawn('bash', argv). + */ + _buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, audioMap = '0:a:0?' }) { + const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate); + const vb = videoBitrate || GROWING_DEFAULT_BITRATE; + const ach = audioChannels ? Number(audioChannels) : 2; + + // ffmpeg argv (shell-quoted). One decklink read → yadif → split → 3 outputs. + const sh = (a) => "'" + String(a).replace(/'/g, `'\\''`) + "'"; + // `-y`: the FIFOs are pre-created by mkfifo, so ffmpeg must overwrite them + // without the interactive "File already exists. Overwrite? [y/N]" prompt + // (which would otherwise abort the video/audio outputs and produce nothing). + const ff = ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'warning', '-stats']; + // SDI input is interlaced; yadif then split into the master + preview taps. + // When there's an HLS dir we split the decode into the master ([vhi]) and + // the H.264 preview ([vlo]); with no HLS dir, split=1 (master only) so no + // split output is ever left unconnected (deltacast growing master had no + // HLS dir, leaving [vlo] orphaned -> 'split output 1 (vlo) unconnected'). + const filterComplex = hlsDir + ? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]' + : '[0:v]yadif=mode=1:deint=1,split=1[vhi]'; + const ffArgs = [ + ...inputArgs, + '-filter_complex', filterComplex, + // (a) MPEG-2 422 elementary video → "$VF" + '-map', '[vhi]', + ...GROWING_VIDEO_ELEMENTARY_ARGS, + '-b:v', vb, '-minrate', vb, '-maxrate', vb, '-bufsize', vb, + '-r', ffRate, + '-f', 'mpeg2video', '@VF@', + // (b) PCM s16le audio → "$AF" + '-map', audioMap, + '-c:a', 'pcm_s16le', '-ar', '48000', '-ac', String(ach), + '-f', 's16le', '@AF@', + ]; + let ffHls = []; + if (hlsDir) { + ffHls = [ + // (c) H.264 HLS preview — GPU-gated, unchanged behaviour. + '-map', '[vlo]', '-map', audioMap, + ...buildHlsVideoArgs(videoCodec, framerate), + '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', + '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15', + '-hls_flags', 'delete_segments+append_list+omit_endlist', + '-hls_segment_filename', `${hlsDir}/seg-%05d.ts`, + `${hlsDir}/index.m3u8`, + ]; + } + // @VF@/@AF@ are placeholders for the FIFO path shell variables; emit them as + // unquoted "$VF"/"$AF" so the shell expands them, and shell-quote everything + // else. + const placeholder = (t) => (t === '@VF@' ? '"$VF"' : t === '@AF@' ? '"$AF"' : sh(t)); + const ffLine = [...ff, ...ffArgs, ...ffHls].map(placeholder).join(' '); + + // raw2bmx argv. Audio is de-interleaved by raw2bmx into mono PCM tracks + // (the standard MXF mapping); --part starts a new body partition + + // IndexTableSegment every GROWING_PART_INTERVAL_FRAMES frames. + // + // CLIP TYPE: rdd9 (SMPTE RDD-9 / "Sony MXF") — NOT plain op1a and NOT + // --avid-gf. This is the make-or-break choice for Adobe Premiere: + // * --avid-gf produces an *Avid OP-Atom* growing file. That flavour needs a + // companion AAF to register the clip and is only read live by Avid Media + // Composer — Premiere cannot open it as a growing file. (Confirmed via the + // bmx mailing list + Softron/Drastic edit-while-ingest docs.) So it is + // removed. + // * Premiere's documented edit-while-ingest path expects XDCAM essence + // (MPEG-2 422 Long GOP, which we emit) wrapped as RDD-9. raw2bmx's `rdd9` + // clip type emits exactly that structure. + // --index-follows: write the IndexTableSegment in the *same* partition as the + // essence it indexes (rather than a trailing index-only partition). This is + // what lets a reader that re-scans body partitions on refresh find an index + // covering the newly-written frames — required so Premiere can seek past its + // original frame map toward the record head. + // The header Duration still starts at -1 and is only finalised in the footer + // on stop, so the inline Python dur-patch below overwrites the header Duration + // fields with the live frame count every 3s (Premiere reads the header + // Duration on each refresh; without the patch it sees duration=N/A). + const bmx = [ + 'raw2bmx', '-t', 'rdd9', '-o', '"$OUT"', '-f', frameRate, + '--part', String(GROWING_PART_INTERVAL_FRAMES), + '--index-follows', + rawFlag, '"$VF"', + '-s', '48000', '-q', '16', '--audio-chan', String(ach), '--pcm', '"$AF"', + ]; + const bmxLine = bmx + .map((t) => (t.startsWith('"$') ? t : sh(t))) + .join(' '); + + // The orchestration script. `set -m` is intentionally NOT used; we manage + // children explicitly. Priming FDs 7/8; children close them before exec. + // PATCHPID: inline Python duration-patcher that runs alongside raw2bmx and + // patches the MXF header's Duration=-1 fields with the actual frame count + // every 3 seconds. Without this Premiere sees Duration=N/A even as the file + // grows, so the timeline never extends. The patcher reads the last body + // partition's IndexTableSegment (IndexStartPosition+IndexDuration) to get + // an exact frame count, then seeks back to the header Duration fields and + // overwrites them in-place. It is killed by the cleanup trap on exit. + const script = ` +set -u +VF=$(mktemp -u /tmp/grow_v.XXXXXX); AF=$(mktemp -u /tmp/grow_a.XXXXXX) +OUT=${sh(outPath)} +mkfifo "$VF" "$AF" +PATCHPID= +cleanup() { rm -f "$VF" "$AF"; [ -n "$PATCHPID" ] && kill "$PATCHPID" 2>/dev/null; } +trap cleanup EXIT +# Prime both FIFOs read-write (non-blocking) to break the open-order deadlock. +exec 7<>"$VF" 8<>"$AF" +# raw2bmx: close priming FDs (no stray writer) before exec so it sees real EOF. +( exec 7>&- 8>&-; exec ${bmxLine} ) & +BMXPID=$! +# ffmpeg: also closes priming FDs; it opens its own write ends. +( exec 7>&- 8>&-; exec ${ffLine} ) & +FFPID=$! +# Forward a clean stop to ffmpeg; raw2bmx then gets EOF and finalizes the footer. +stop() { kill -INT "$FFPID" 2>/dev/null; } +trap stop INT TERM +# Drop the parent priming FDs once raw2bmx has opened BOTH FIFOs, so ffmpeg is +# the sole writer (its EOF reaches raw2bmx). If raw2bmx dies early, bail. +for i in $(seq 1 200); do + kill -0 "$BMXPID" 2>/dev/null || break + n=$(ls -l /proc/$BMXPID/fd 2>/dev/null | grep -c -- "$VF\\|$AF") + [ "\${n:-0}" -ge 2 ] && break + sleep 0.1 +done +exec 7>&- 8>&- +# No header-duration patcher is needed. In this bmx v1.6 build, raw2bmx's rdd9 +# writer with --part maintains a live, correct header Duration as the file grows +# (verified on-node: ffprobe reads a growing duration mid-write, e.g. 2.04s of a +# 10s clip while still recording). A patcher (the earlier dur-patch.py) was a +# no-op here — it searched for Duration=-1, which rdd9 never writes — and opening +# the file r+b while raw2bmx appends over CIFS only adds concurrency risk. +PATCHPID= +# Wait for ffmpeg (source end), then for raw2bmx to finalize the footer. +wait "$FFPID"; FFRC=$? +wait "$BMXPID"; BMXRC=$? +echo "[grow] ffmpeg rc=$FFRC raw2bmx rc=$BMXRC out=$OUT" >&2 +exit "$BMXRC" +`; + return ['-c', script]; + } + + /** + * 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, + projectId, + binId, + clipName, + device, + // Deltacast: one board (index 0) with 8 channels. `port` selects the + // channel; `board` selects the physical board (default 0). + port, + board, + sourceType = 'sdi', + // Source-backend selection for SDI capture (issue #168). Defaults to + // `blackmagic` (DeckLink) so existing recorders are unaffected. + sourceBackend = 'blackmagic', + sourceUrl, + 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) { + throw new Error('Capture already in progress'); + } + + const sessionId = uuidv4(); + const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4'; + + // Growing-files: write master to the SMB share instead of streaming to S3. + // Path is relative to the container's GROWING_PATH mount. + // + // Approach A: if a CIFS source is configured, mount it now. A mount failure + // is non-fatal — we fall back to S3 streaming so the recording is never + // lost. + let growingActive = GROWING_ENABLED; + if (growingActive && GROWING_SMB_MOUNT) { + if (!mountGrowingShare()) growingActive = false; // fall back to S3 + } + // Growing master is always MXF OP1a / XDCAM HD422 written by raw2bmx (the + // format Premiere reads while growing — see GROWING_VIDEO_ELEMENTARY_ARGS / + // _buildGrowingOrchestrator), regardless of the recorder's configured + // container — so it gets a .mxf extension, not the container's. + const growingPath = growingActive + ? `${GROWING_PATH}/${projectId}/${clipName}.${GROWING_EXT}` + : null; + + // hiresKey MUST match the actual master format/destination: + // - growing active → the master is a growing OP1a MXF on the share; the + // promotion worker uploads it to this key, so it has the .mxf extension. + // (A stale .mov key here would make the proxy job download a nonexistent + // object → "unable to open the file on disk".) + // - growing fell back to S3 → the normal container extension. + const hiresExt = growingPath ? GROWING_EXT : (CONTAINER_EXT[container] || 'mov'); + const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`; + if (growingPath) { + try { mkdirSync(dirname(growingPath), { recursive: true }); } + catch (err) { console.error('[capture] could not create growing dir:', err.message); } + } + + // DeckLink hardware does NOT support concurrent capture from the same port. + // Opening a second ffmpeg process on the same DeckLink input while the first + // is already capturing causes "Cannot Autodetect input stream or No signal" + // on the second process — making the proxy empty and potentially crashing the + // container before the hires upload completes. + // + // Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ + // worker generate the proxy from the hires master after the recording stops. + // The stop handler sets needsProxy=true so the worker picks it up. + const proxyKey = null; + + const startedAt = new Date().toISOString(); + + this._sessionIdForBridge = sessionId; + const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false } = await this._buildInputArgs({ + sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey, + }); + + // Audio input index: the deltacast bridge delivers audio on a separate + // FIFO wired as ffmpeg input 1, whereas DeckLink SDI and network sources + // carry audio inside input 0. (bridgeProcess is set only for deltacast.) + const audioMap = bridgeProcess ? '1:a:0?' : '0:a:0?'; + + // Non-growing master: ffmpeg muxes the finalized MOV directly. Growing + // master: raw2bmx muxes the OP1a from elementary FIFOs (handled below via + // the orchestrator), so we don't build ffmpeg codec args here for it. + const hiresCodecArgs = growingPath ? null : buildEncodeArgs({ + codec: videoCodec, videoBitrate, framerate, + audioCodec, audioBitrate, audioChannels, + container, + isNetwork, + isProxy: false, + }); + + if (hiresCodecArgs) console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' ')); + + const isInterlacedSource = sourceType === 'sdi' || (sourceType === 'deltacast' && interlaced); + const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : []; + + // Master output destination (NON-growing path only). + // + // - Growing-files on → the growing OP1a MXF is written directly to the SMB + // share by raw2bmx (see the orchestrator below); ffmpeg only produces the + // elementary essence FIFOs + HLS preview. `localMasterPath`/`hiresOutput` + // are unused in this case (the master path is `growingPath`). + // + // - Growing-files off → ffmpeg writes the MOV master to a LOCAL SEEKABLE + // temp file, then we upload to S3 on stop. We must NOT pipe the MOV muxer + // to S3 directly: the MOV/MP4 muxer cannot write to a non-seekable pipe + // without `empty_moov`, and an empty_moov/fragmented MOV is exactly what + // makes Adobe Premiere report "file cannot be opened" (no classic + // stco/stsz sample tables — samples live in moof/trun). A seekable file + // lets ffmpeg write a single contiguous moov with full sample tables and + // `+faststart` moves it to the front, producing a Premiere-native master. + const localMasterPath = growingPath + ? null + : `/tmp/capture/${sessionId}.${hiresExt}`; + if (localMasterPath) { + try { mkdirSync(dirname(localMasterPath), { recursive: true }); } + catch (err) { console.error('[capture] could not create temp master dir:', err.message); } + } + const hiresOutput = localMasterPath; + const hiresStdio = [bridgeProcess ? 'pipe' : 'ignore', 'ignore', 'pipe']; + + // For SDI we cannot open the DeckLink device a second time for a preview + // tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires + // ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS]. + let sdiHlsDir = null; + if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) { + const fsMod = await import('node:fs'); + sdiHlsDir = '/live/' + this._assetIdForHls; + try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {} + } + + let hiresProcess; + if (growingPath) { + // ── GROWING master: raw2bmx orchestrator ────────────────────────── + // One ffmpeg (single SDI read) → MPEG-2 422 elementary + PCM to FIFOs + + // the H.264 HLS preview; raw2bmx muxes the growing OP1a MXF from the FIFOs. + // Spawned via bash so the FIFO priming / EOF / stop-forwarding logic (see + // _buildGrowingOrchestrator) runs as one supervised unit. detached:true so + // it leads its own process group and we can clean-stop the whole pipeline. + const orchArgs = this._buildGrowingOrchestrator({ + inputArgs, + videoBitrate, + // Recorder raster for the raw2bmx essence flag. recorders.js sets + // RECORDING_RESOLUTION (e.g. '1920x1080' / '1080i' / 'native'); when + // 'native'/absent, deriveGrowingRaster defaults to 1080i59.94. + resolution: process.env.RECORDING_RESOLUTION || null, + framerate, + audioChannels, + outPath: growingPath, + hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null, + videoCodec, + audioMap, + }); + console.log('[capture] growing master via raw2bmx; orchestrator script length=' + orchArgs[1].length); + hiresProcess = spawn('bash', orchArgs, { stdio: ['ignore', 'ignore', 'pipe'], detached: true }); + } else { + // ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ── + let hiresArgs; + if ((sourceType === 'sdi' || sourceType === 'deltacast') && this._assetIdForHls) { + hiresArgs = [ + ...inputArgs, + '-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]', + // Output 0 — ProRes/MOV master (local temp, uploaded to S3 on stop) + '-map', '[vhi]', '-map', audioMap, + ...hiresCodecArgs, + hiresOutput, + // Output 1 — low-latency H.264 HLS preview for the UI monitor. + // GPU-encoded (h264_nvenc) when the GPU is attached to this sidecar, + // otherwise libx264 (issue #164). GOP is pinned to one IDR per HLS + // segment so segments start on keyframes (avoids black/flashing). + '-map', '[vlo]', '-map', audioMap, + ...buildHlsVideoArgs(videoCodec, framerate), + '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', + '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15', + '-hls_flags', 'delete_segments+append_list+omit_endlist', + '-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts', + sdiHlsDir + '/index.m3u8', + ]; + console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir); + } else { + hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ]; + } + hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio }); + if (bridgeProcess) { + bridgeProcess.stdout.pipe(hiresProcess.stdin); + } + } + + // Growing-files: nothing to upload here (promotion worker handles S3). + // Non-growing: the master is uploaded from the finalized local file in + // stop(), once ffmpeg has written the moov and exited cleanly — we can't + // upload while recording because the file isn't a valid MOV until finalize. + const processes = { hires: hiresProcess }; + if (bridgeProcess) { + processes.bridge = bridgeProcess; + bridgeProcess.on('exit', (code) => { + if (code !== 0 && code !== null) { + console.error(`[deltacast-bridge] exited with code ${code}`); + this.state.lastError = `deltacast bridge exited: code ${code}`; + } + }); + } + const uploads = { hires: growingPath ? Promise.resolve({ growingPath }) : null }; + + // ── HLS tee for network sources (live preview in the UI) ────────── + let hlsProcess = null; + let hlsDir = null; + if (isNetwork && this._assetIdForHls) { + try { + const fs = await import('node:fs'); + hlsDir = '/live/' + this._assetIdForHls; + fs.mkdirSync(hlsDir, { recursive: true }); + const hlsArgs = [ + ...inputArgs, + '-map', '0:v:0?', '-map', '0:a:0?', + // GPU-gated preview encode, same as the SDI 2nd-output path (#164). + ...buildHlsVideoArgs(videoCodec, framerate), + '-c:a', 'aac', '-b:a', '128k', '-ar', '44100', + '-f', 'hls', '-hls_time', '2', '-hls_list_size', '15', + '-hls_flags', 'delete_segments+append_list+omit_endlist', + '-hls_segment_filename', hlsDir + '/seg-%05d.ts', + hlsDir + '/index.m3u8', + ]; + hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] }); + hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); }); + hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c)); + processes.hls = hlsProcess; + console.log('[HLS] tee started -> ' + hlsDir); + } catch (err) { + console.error('[HLS] tee failed:', err.message); + } + } + + hiresProcess.stderr.on('data', (data) => { + const text = data.toString(); + console.error(`[HIRES] ${text}`); + 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(); + } + 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); + } + }); + + // Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP). + // DeckLink hardware does not support two concurrent readers on the same port. + + this.state.recording = true; + this.state.sessionId = sessionId; + this.state.processes = processes; + this.state.framesReceived = 0; + this.state.currentFps = 0; + this.state.lastFrameAt = null; + this.state.lastError = null; + this.state.currentSession = { + sessionId, + projectId, + binId, + clipName, + device, + sourceType, + sourceUrl, + hiresKey, + proxyKey, + growingPath, + localMasterPath, + audioFifo, + startedAt, + duration: 0, + uploads, + codecs: { + videoCodec, videoBitrate, framerate, + audioCodec, audioBitrate, audioChannels, container, + proxyEnabled, proxyVideoCodec, proxyVideoBitrate, + proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer, + }, + }; + + return this._formatSessionResponse(); + } + + async stop(sessionId) { + if (!this.state.recording || this.state.sessionId !== sessionId) { + throw new Error('No active capture session or session ID mismatch'); + } + + const { processes, currentSession } = this.state; + + const isGrowing = !!currentSession.growingPath; + + // Send SIGINT and WAIT for the master writer to exit cleanly. + // - Non-growing: SIGINT flushes ffmpeg's MOV trailer (the moov atom with + // full sample tables). Uploading before finalize → "moov atom not found". + // - Growing: `processes.hires` is the bash ORCHESTRATOR (detached group + // leader). SIGINT hits its trap, which forwards SIGINT to ffmpeg; ffmpeg + // stops → raw2bmx gets EOF → raw2bmx writes the OP1a FOOTER and exits; + // only then does the orchestrator exit. Awaiting it guarantees the + // finalized, valid MXF is on the share before the promotion worker + // uploads it. raw2bmx footer finalize of a long recording can take longer + // than a MOV trailer flush, so the growing safety-net is more generous. + const finalizeTimeoutMs = isGrowing ? 60000 : 15000; + const waitExit = (proc) => new Promise((resolve) => { + if (!proc || proc.exitCode !== null || proc.signalCode !== null) return resolve(); + let done = false; + const finish = () => { if (!done) { done = true; resolve(); } }; + proc.once('exit', finish); + // Safety net: don't hang stop() forever if the writer refuses to exit. + setTimeout(() => { + try { + // Detached orchestrator → kill the whole process group (ffmpeg + + // raw2bmx + bash); otherwise just the process. + if (isGrowing && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch (_) {} } + proc.kill('SIGKILL'); + } catch (_) {} + finish(); + }, finalizeTimeoutMs); + }); + + 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.bridge) { try { processes.bridge.kill('SIGINT'); } catch (_) {} } + + // Wait for the master writer to finalize before we read/upload the file. + await waitExit(processes.hires); + + // Release the CIFS mount (best-effort) once the ffmpeg writers are done with + // it. The promotion worker reads the staged file from the host/S3 side, not + // through this container's mount, so unmounting here is safe. + unmountGrowingShare(); + + try { + const uploadPromises = []; + + // Non-growing: upload the finalized local master file to S3 now that the + // moov has been written. Growing: the promotion worker handles S3. + if (currentSession.localMasterPath) { + let size = 0; + try { size = statSync(currentSession.localMasterPath).size; } catch (_) {} + if (size > 0) { + uploadPromises.push( + createUploadStream( + S3_BUCKET, + currentSession.hiresKey, + createReadStream(currentSession.localMasterPath), + ).then(() => { + try { unlinkSync(currentSession.localMasterPath); } catch (_) {} + }) + ); + } else { + console.warn('[capture] local master is 0 bytes — skipping upload:', currentSession.localMasterPath); + } + } else if (currentSession.uploads.hires) { + uploadPromises.push(currentSession.uploads.hires); + } + + if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy); + await Promise.all(uploadPromises); + } catch (error) { + console.error('Error during upload completion:', error); + } + + if (currentSession.audioFifo) { + try { unlinkSync(currentSession.audioFifo); } catch (_) {} + } + + const stoppedAt = new Date().toISOString(); + const startTime = new Date(currentSession.startedAt); + const stopTime = new Date(stoppedAt); + const duration = Math.round((stopTime - startTime) / 1000); + + this.state.recording = false; + this.state.sessionId = null; + this.state.processes = {}; + + // No frames received → the upload (if any) produced a 0-byte object. + // Surface that so the shutdown handler can mark the asset as 'error' + // instead of posting a broken hi-res key downstream. + const framesReceived = this.state.framesReceived; + + return { + sessionId, + projectId: currentSession.projectId, + binId: currentSession.binId, + clipName: currentSession.clipName, + sourceType: currentSession.sourceType, + hiresKey: currentSession.hiresKey, + proxyKey: currentSession.proxyKey, + growingPath: currentSession.growingPath || null, + startedAt: currentSession.startedAt, + stoppedAt, + duration, + framesReceived, + empty: framesReceived === 0, + }; + } + + getStatus() { + if (!this.state.recording) return { recording: false }; + + const startTime = new Date(this.state.currentSession.startedAt); + const now = new Date(); + const duration = Math.round((now - startTime) / 1000); + + const lastFrameAt = this.state.lastFrameAt; + const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null; + let signal = 'connecting'; + if (this.state.framesReceived > 0) { + signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost'; + } else if (this.state.lastError) { + signal = 'error'; + } + return { + recording: true, + sessionId: this.state.sessionId, + sourceType: this.state.currentSession.sourceType, + device: this.state.currentSession.device, + clipName: this.state.currentSession.clipName, + projectId: this.state.currentSession.projectId, + binId: this.state.currentSession.binId, + duration, + startedAt: this.state.currentSession.startedAt, + signal, + framesReceived: this.state.framesReceived, + currentFps: this.state.currentFps, + lastFrameAt, + msSinceFrame, + lastError: this.state.lastError, + codecs: this.state.currentSession.codecs, + }; + } + + _formatSessionResponse() { + const { currentSession, sessionId } = this.state; + return { + sessionId, + projectId: currentSession.projectId, + binId: currentSession.binId, + clipName: currentSession.clipName, + device: currentSession.device, + sourceType: currentSession.sourceType, + 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, sourceBackends };