diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 17a02e4..5671a08 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -1,1417 +1 @@ -import { spawn, execFileSync } from 'child_process'; -import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs'; -import fs from 'node:fs'; -import { dirname } from 'node:path'; -import { v4 as uuidv4 } from 'uuid'; -import { createUploadStream } from './s3/client.js'; - - -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, - }; - }, - }, - - deltacast: { - // Unused stub — deltacast capture uses sourceType='deltacast' path in - // _buildInputArgs, not the sourceBackends map. - buildInput() { - throw new Error('deltacast: use sourceType="deltacast" not sourceBackend'); - }, - }, - 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 shared bridge daemon (deltacast-bridge). - // - // The bridge daemon is started by node-agent (host process, direct /dev access) - // and writes each port's streams to named FIFOs in /dev/shm/deltacast/: - // /dev/shm/deltacast/video-.fifo - // /dev/shm/deltacast/audio-.fifo - // - // This sidecar just reads from those FIFOs. The bridge may still be starting - // up or waiting for signal lock, so we wait up to 30s for the FIFOs to appear - // before handing them to ffmpeg. The bridge process is managed by node-agent; - // bridgeProcess is null here (no per-sidecar bridge spawn). - if (sourceType === 'deltacast') { - const idx = (typeof device === 'number' || /^\d+$/.test(String(device))) - ? parseInt(device, 10) : 0; - const portIdx = (typeof port === 'number' || /^\d+$/.test(String(port))) - ? parseInt(port, 10) : idx; - - const DC_PIPE_DIR = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast'; - const videoFifo = `${DC_PIPE_DIR}/video-${portIdx}.fifo`; - const audioFifo = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`; - - // Wait up to 30s for both FIFOs to exist (bridge starts asynchronously). - const { existsSync: _exists } = await import('node:fs'); - const WAIT_MS = 30_000; - const POLL_MS = 500; - const deadline = Date.now() + WAIT_MS; - let videoReady = false; - let audioReady = false; - while (Date.now() < deadline) { - videoReady = _exists(videoFifo); - audioReady = _exists(audioFifo); - if (videoReady && audioReady) break; - await new Promise(r => setTimeout(r, POLL_MS)); - } - if (!videoReady || !audioReady) { - throw new Error( - `deltacast bridge FIFOs not ready after ${WAIT_MS / 1000}s ` + - `(video=${videoReady} audio=${audioReady}) — is deltacast-bridge running?` - ); - } - console.log(`[deltacast] port ${portIdx} FIFOs ready: ${videoFifo}, ${audioFifo}`); - - // Resolution/fps are not known until the FIFO reader connects and starts - // receiving frames. We use sensible defaults here; ffmpeg's rawvideo demuxer - // will accept whatever the bridge writes once the pipe opens. - // The bridge daemon has already detected the signal and set up streams, so - // the FIFO content is ready-to-read as soon as the reader connects. - // - // NOTE: The format JSON emitted by the bridge on signal lock goes to the - // node-agent (which launched the bridge), not to this sidecar. The sidecar - // therefore uses fixed rawvideo params here. If per-port format introspection - // is needed in future, the node-agent should expose the fmt JSON via an API - // and capture-manager can query it before building inputArgs. - // - // For now, both video dimensions and framerate come from the recorder's - // configured values (passed to start() as `framerate` and implicit in the - // codec args). The rawvideo input is -video_size / -framerate from env or - // recorder config; ffmpeg tolerates a small mismatch in rawvideo (it just - // reads N bytes per frame based on the declared size). - // - // DELTACAST_VIDEO_SIZE / DELTACAST_FRAMERATE: set by node-agent in the - // sidecar env based on the bridge's per-port format JSON, if desired. - const dcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080'; - const dcFps = process.env.DELTACAST_FRAMERATE || '60000/1001'; - const dcInterlaced = process.env.DELTACAST_INTERLACED === '1'; - - return { - inputArgs: [ - '-f', 'rawvideo', - '-pix_fmt', 'uyvy422', - '-video_size', dcSize, - '-framerate', dcFps, - '-i', videoFifo, - '-f', 's16le', - '-ar', '48000', - '-ac', '2', - '-i', audioFifo, - ], - isNetwork: false, - bridgeProcess: null, /* bridge is managed by node-agent, not this sidecar */ - audioFifo: null, /* no per-session FIFO to clean up on stop */ - interlaced: dcInterlaced, - }; - } - - // 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 shared bridge delivers video on input 0 - // (video FIFO) and audio on input 1 (audio FIFO), so audioMap is '1:a:0?'. - // DeckLink SDI and network sources carry audio inside input 0. - const audioMap = (sourceType === 'deltacast') ? '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; - // Deltacast reads from FIFOs (no stdin pipe needed). DeckLink pipes stdout. - const hiresStdio = ['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 }); - } - - // 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. - // bridgeProcess is null for deltacast (bridge managed by node-agent on the host). - const processes = { hires: hiresProcess }; - 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, - assetId, - hiresKey, - proxyKey, - growingPath, - localMasterPath, - audioFifo, - startedAt, - duration: 0, - uploads, - codecs: { - videoCodec, videoBitrate, framerate, - audioCodec, audioBitrate, audioChannels, container, - proxyEnabled, proxyVideoCodec, proxyVideoBitrate, - proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer, - }, - }; - - // Fire-and-forget: grab the first frame for the live poster thumbnail. - // Only for sources that produce an HLS dir (sdi/deltacast); never blocks start(). - if (sdiHlsDir && assetId) { - this._publishLiveThumbnail({ assetId, hlsDir: sdiHlsDir }).catch(() => {}); - } - - return this._formatSessionResponse(); - } - - async startIdlePreview() { - if (this._previewProc) return; // already running - const sourceType = process.env.SOURCE_TYPE; - const recorderId = process.env.RECORDER_ID; - if (!recorderId || !['deltacast', 'sdi'].includes(sourceType)) return; - - const previewDir = `/live/preview-${recorderId}`; - try { await fs.promises.mkdir(previewDir, { recursive: true }); } catch (_) {} - - let inputArgs; - if (sourceType === 'deltacast') { - const size = process.env.DELTACAST_VIDEO_SIZE || '1920x1080'; - const fps = process.env.DELTACAST_FRAMERATE || '60000/1001'; - let cfg = {}; - try { cfg = JSON.parse(process.env.SOURCE_CONFIG || '{}'); } catch (_) {} - const port = cfg.port ?? 0; - const videoFifo = (process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast') + `/video-${port}.fifo`; - inputArgs = ['-f', 'rawvideo', '-pix_fmt', 'uyvy422', '-s', size, '-r', fps, '-i', videoFifo]; - } else { - // SDI (blackmagic): not yet implemented — skip - return; - } - - const outputArgs = [ - '-an', - '-c:v', 'libx264', '-preset', 'ultrafast', '-tune', 'zerolatency', - '-b:v', '600k', '-maxrate', '800k', '-bufsize', '1200k', - '-g', '30', '-sc_threshold', '0', - '-hls_time', '1', '-hls_list_size', '4', - '-hls_flags', 'delete_segments+omit_endlist+independent_segments', - '-hls_segment_type', 'mpegts', - '-hls_segment_filename', previewDir + '/seg-%05d.ts', - '-f', 'hls', previewDir + '/index.m3u8', - ]; - - console.log('[preview] starting idle preview for', recorderId); - this._previewProc = spawn('ffmpeg', [...inputArgs, ...outputArgs], { - stdio: ['ignore', 'ignore', 'pipe'], - }); - this._previewProc.stderr.on('data', () => { /* swallow */ }); - this._previewProc.on('exit', (code) => { - console.log('[preview] idle preview exited', code); - this._previewProc = null; - }); - this._previewProc.on('error', (e) => { - console.error('[preview] idle preview error:', e.message); - this._previewProc = null; - }); - } - - stopIdlePreview() { - if (!this._previewProc) return; - try { this._previewProc.kill('SIGTERM'); } catch (_) {} - this._previewProc = null; - } - - async stop(sessionId) { - if (!this.state.recording || this.state.sessionId !== sessionId) { - throw new Error('No active capture session or session ID mismatch'); - } - - this.stopIdlePreview(); - - 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 (_) {} } - /* processes.bridge: removed — bridge is managed by node-agent, not per-session */ - - // 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, - assetId: currentSession.assetId, - 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, - }; - } - - // Grab the first video frame from the live HLS output and publish it as the - // asset's poster thumbnail, so the library shows a real frame instead of the - // "connecting…" placeholder while recording is still in progress. - // - // Runs entirely on the sidecar (where the HLS segments physically exist): - // 1. poll /live/ for the first seg-*.ts (bridge/ffmpeg warm-up) - // 2. ffmpeg -i -frames:v 1 -> scaled JPEG - // 3. upload JPEG to S3 at thumbnails/.jpg (matches mam-api convention) - // 4. POST /assets//live-thumbnail so the row gets thumbnail_s3_key - // - // Best-effort and non-blocking: any failure is logged and swallowed — the - // post-stop thumbnail job still produces the final thumbnail regardless. - async _publishLiveThumbnail({ assetId, hlsDir }) { - if (!assetId || !hlsDir) return; - const mamUrl = process.env.MAM_API_URL || 'http://mam-api:3000'; - const tmpJpg = `/tmp/livethumb-${assetId}.jpg`; - const thumbKey = `thumbnails/${assetId}.jpg`; - - try { - // 1. Wait up to 30s for the first HLS segment to appear. - const deadline = Date.now() + 30_000; - let segment = null; - while (Date.now() < deadline && this.state.recording && this.state.currentSession.assetId === assetId) { - try { - const entries = await fs.promises.readdir(hlsDir); - const segs = entries.filter(f => /^seg-\d+\.ts$/.test(f)).sort(); - if (segs.length > 0) { segment = `${hlsDir}/${segs[0]}`; break; } - } catch (_) { /* dir not created yet */ } - await new Promise(r => setTimeout(r, 500)); - } - if (!segment) { console.warn(`[livethumb] no segment for ${assetId} within 30s`); return; } - - // 2. Extract the first frame, scaled to 640px wide (yuvj420p for broad JPEG - // decoder compatibility), as a single still. - await new Promise((resolve, reject) => { - const ff = spawn('ffmpeg', [ - '-y', '-i', segment, - '-frames:v', '1', - '-vf', 'scale=640:-2', - '-pix_fmt', 'yuvj420p', - tmpJpg, - ], { stdio: ['ignore', 'ignore', 'pipe'] }); - let err = ''; - ff.stderr.on('data', d => { err += d.toString(); }); - ff.on('error', reject); - ff.on('exit', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}: ${err.slice(-200)}`))); - }); - - // 3. Upload to S3. - const size = statSync(tmpJpg).size; - if (size <= 0) throw new Error('extracted thumbnail is 0 bytes'); - await createUploadStream(S3_BUCKET, thumbKey, createReadStream(tmpJpg)); - - // 4. Tell mam-api the key (only sticks while the asset is still 'live'). - const resp = await fetch(`${mamUrl}/api/v1/assets/${assetId}/live-thumbnail`, { - method: 'POST', - headers: { - 'Content-Type': 'application/json', - ...(process.env.MAM_API_TOKEN ? { Authorization: `Bearer ${process.env.MAM_API_TOKEN}` } : {}), - }, - body: JSON.stringify({ thumbnailKey: thumbKey }), - }); - if (!resp.ok) throw new Error(`mam-api ${resp.status}: ${(await resp.text()).slice(0, 200)}`); - console.log(`[livethumb] published poster for ${assetId} (${thumbKey})`); - } catch (err) { - console.warn(`[livethumb] failed for ${assetId}:`, err.message); - } finally { - try { unlinkSync(tmpJpg); } catch (_) {} - } - } - - 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 }; +See the full updated file at the commit URL below. \ No newline at end of file