import { spawn, execFileSync } from 'child_process'; import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs'; import { dirname } from 'node:path'; import { v4 as uuidv4 } from 'uuid'; import { createUploadStream } from './s3/client.js'; /** * 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) => { let buf = ''; let settled = false; const settle = (fn) => { if (settled) return; settled = true; fn(); }; const timer = setTimeout(() => { settle(() => reject(new Error(`deltacast-capture: timed out waiting for format JSON after ${timeoutMs}ms`))); }, timeoutMs); proc.stderr.setEncoding('utf8'); proc.stderr.on('data', (chunk) => { buf += chunk; const nl = buf.indexOf('\n'); if (nl === -1) return; const line = buf.slice(0, nl).trim(); 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}`))); } }); proc.on('exit', (code) => { clearTimeout(timer); settle(() => reject(new Error(`deltacast-capture: exited with code ${code} before emitting format JSON`))); }); }); } 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 = '50M'; 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 };