dragonflight/services/capture/src/capture-manager.js
OpenCode 9b4677cec7 refactor(capture): remove dead raw2bmx/AVC-Intra/HEVC growing code paths
Frame-coupled VC-3/DNxHD MXF (_buildGrowingVc3Mxf) is now the only growing
master path. Delete provably-unreferenced legacy builders and their constants,
and update stale comments to match the VC-3 reality. No live-path behavior
change: the removed stop() raw2bmx branch was gated on a hard-coded false, so
the kept SIGINT path is byte-identical to the previously-executed else branch.

Removed (all confirmed unreferenced repo-wide outside this file):
- _buildGrowingOrchestrator() + JSDoc (raw2bmx bash orchestrator)
- _buildGrowingHevcMov() + JSDoc (HEVC fragmented-MOV growing)
- deriveGrowingRaster() (raw2bmx raster-flag mapper)
- growingVideoElementaryArgs() + GROWING_AVCI_CLASS
- GROWING_DEFAULT_BITRATE, GROWING_PART_INTERVAL_FRAMES,
  GROWING_HEVC_DEFAULT_BITRATE, GROWING_VC3_CODECS (unused Set)
- isRaw2bmxGrowing dead branch in stop()
- stale raw2bmx/XDCAM HD422/AVC-Intra/frozen-picture comment blocks

Kept (live): _buildGrowingVc3Mxf, growingCodec, growingVc3Bitrate,
growingExtFor, GROWING_EXT, audioOffsetArgs (+AUDIO_OFFSET_MS), all
non-growing + network/SRT/RTMP/fc_pipe paths.

node --check passes.
2026-06-05 14:28:36 +00:00

1414 lines
67 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync } 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';
// In standby mode the framecache slot has been warm for a long time — reduce
// pre-roll to 1s (just enough for fc_pipe to sync its read cursor).
// Override with PRE_ROLL_SECONDS env var if needed.
const _standbyMode = process.env.STANDBY === '1';
const PRE_ROLL_SECONDS = parseInt(process.env.PRE_ROLL_SECONDS || (_standbyMode ? '1' : '5'), 10);
// 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`, delivered per-session on
// /capture/start (read fresh from process.env at record time in start(), NOT
// cached here — standby sidecars boot with it false).
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;
}
// Growing SMB params are read FRESH from process.env at mount time, NOT cached
// at module load. Standby capture containers boot with these unset and receive
// them per-session over /capture/start (capture.js sets process.env before
// captureManager.start()). Caching them in module-level consts at import time
// captured the empty boot values, so the mount silently no-op'd and growing
// fell back to S3 — producing .mov instead of the VC-3/DNxHD .mxf.
const growingSmbConfig = () => ({
mount: toUncShare(process.env.GROWING_SMB_MOUNT || ''),
username: process.env.GROWING_SMB_USERNAME || '',
password: process.env.GROWING_SMB_PASSWORD || '',
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() {
const cfg = growingSmbConfig();
if (!cfg.mount) {
console.warn('[capture] growing requested but GROWING_SMB_MOUNT is empty — falling back to S3');
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 (_) {}
// Pass credentials inline rather than via a credentials= file. Some SMB
// servers (notably TrueNAS SMB3) reject the credentials-file form with
// EACCES (-13) — "cannot mount ... read-only" — even though the very same
// username/password mount inline and smbclient lists the share fine. Inline
// user=/password= is the reliable form here.
const opts = [
`username=${cfg.username}`,
`password=${cfg.password}`,
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
`vers=${cfg.vers}`,
].join(',');
execFileSync('mount', ['-t', 'cifs', cfg.mount, GROWING_PATH, '-o', opts],
{ stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] mounted CIFS growing share', cfg.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() {
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).
// GROWING-file variant: every frame an IDR (all-intra) so a still-growing
// file is decodable to its last complete frame. This is HEAVY — only used when
// growing-files is on (see hevcNvencArgs()).
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',
},
};
// HEVC/NVENC encode args, GOP structure chosen by mode.
// growing=false (normal record): efficient long-GOP (2s @ fps) HEVC. NVENC
// easily sustains 1080p59.94 10-bit here, so no frame drops → audio/video
// lengths stay locked. This is the DEFAULT for recorders.
// growing=true (edit-while-record): ALL-INTRA (every frame an IDR) so the
// growing file is decodable to its last written frame — the requirement for
// Premiere's growing-file refresh. Much heavier, only used when needed.
// `force_key_frames expr:1` (all-intra) is the ~4× compute path that was
// crippling realtime when applied to every recording; gating it on `growing`
// is the fix for the dropped-frame A/V drift.
// Parse a framerate that may be a rational like "60000/1001" (=59.94) OR a plain
// "59.94"/"60". Number.parseFloat("60000/1001") returns 60000 (stops at '/'),
// which made the GOP 120000 instead of ~120 — effectively open-GOP. Handle the
// rational form explicitly.
function parseFps(framerate, fallback = 60) {
if (framerate == null) return fallback;
const s = String(framerate).trim();
if (s.includes('/')) {
const [n, d] = s.split('/').map(Number);
if (Number.isFinite(n) && Number.isFinite(d) && d !== 0) return n / d;
}
const f = Number.parseFloat(s);
return Number.isFinite(f) && f > 0 ? f : fallback;
}
// Which physical GPU this sidecar's NVENC encodes should use. node-agent
// round-robins capture ports across the host's GPUs and passes the index here.
// We MUST select it explicitly with ffmpeg's `-gpu N` because the capture
// sidecars run Privileged (so they see every /dev/nvidiaN regardless of
// NVIDIA_VISIBLE_DEVICES) — without -gpu, nvenc defaults every session to GPU 0
// and all 8 ports pile onto one card → it falls below realtime → video freezes.
const CAPTURE_GPU_INDEX = (() => {
const v = process.env.CAPTURE_GPU_INDEX;
if (v == null || v === '' || v === 'all') return null;
const n = parseInt(v, 10);
return Number.isInteger(n) && n >= 0 ? n : null;
})();
// `-gpu N` must come BEFORE the input/encoder is initialized; ffmpeg accepts it
// as an encoder option right after -c:v. Returns [] when no pin is configured.
const nvencGpuSel = () => (CAPTURE_GPU_INDEX != null ? ['-gpu', String(CAPTURE_GPU_INDEX)] : []);
// Optional fixed A/V alignment trim for the SDI/Deltacast audio input. The
// deltacast bridge captures audio and video on separate VHD streams; any
// constant capture-path latency difference between them shows as a fixed A/V
// offset (e.g. audio slightly ahead of video) even though stream LENGTHS stay
// locked (no drift). AUDIO_OFFSET_MS lets an operator dial that out without a
// rebuild: POSITIVE value DELAYS audio (use when audio is AHEAD of video),
// NEGATIVE advances it. Applied as ffmpeg `-itsoffset` on the audio input only.
// Default 0 = no change (fully non-destructive). Range-clamped to ±1000 ms.
const audioOffsetArgs = () => {
const raw = parseFloat(process.env.AUDIO_OFFSET_MS || '0');
if (!Number.isFinite(raw) || raw === 0) return [];
const ms = Math.max(-1000, Math.min(1000, raw));
return ['-itsoffset', (ms / 1000).toFixed(4)];
};
function hevcNvencArgs(framerate, growing) {
const base = ['-c:v', 'hevc_nvenc', ...nvencGpuSel(), '-preset', 'p4', '-rc', 'vbr', '-profile:v', 'main10'];
if (growing) {
return [...base, '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1'];
}
// Normal long-GOP: ~2s keyframe interval, 2 B-frames. Realtime-friendly.
const fps = parseFps(framerate, 60);
const gop = Math.max(2, Math.round(fps * 2));
return [...base, '-bf', '2', '-g', String(gop)];
}
// 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 = parseFps(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', ...nvencGpuSel(), '-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 — VC-3 / DNxHD in MXF OP1a,
// written DIRECTLY by ffmpeg's native MXF muxer (NO raw2bmx, NO FIFO, NO
// elementary-essence orchestration). ffmpeg writes a frame-wrapped OP1a whose
// BODY grows readably while still being written: the partial file opens as
// 'mxf' and decodes mid-write, and finalizes with a valid Duration + footer on
// a clean SIGINT. This is the same growing VC-3 workflow vMix uses and that
// Adobe Premiere imports live (with "Automatically refresh growing files"
// enabled). The single-input fc_pipe AVI feeds it (video + frame-coupled
// embedded audio in one stream); see _buildGrowingVc3Mxf() and start().
//
// 'vc3_90' -> VC-3 90 Mbps, lighter storage. DEFAULT.
// 'vc3_220' -> VC-3/DNxHD 220 Mbps (VC3_1080p_1238), highest quality.
//
// growingCodec() reads GROWING_CODEC fresh from env at record time (standby
// sidecars boot unset and receive it per-session via /capture/start).
const GROWING_EXT = 'mxf';
const growingCodec = () => {
const v = process.env.GROWING_CODEC;
if (v === 'vc3_220') return 'vc3_220';
return 'vc3_90'; // default
};
// Bitrate for the dnxhd encoder, per growing codec value.
const growingVc3Bitrate = (codec) => (codec === 'vc3_220' ? '220M' : '90M');
// File extension per growing codec — always MXF for VC-3.
const growingExtFor = (_codec) => 'mxf';
// ── 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 here — the growing VC-3/DNxHD MXF is
// built by _buildGrowingVc3Mxf() and spawned directly in start(). So
// buildEncodeArgs is never 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?');
// hevc_nvenc GOP structure is mode-dependent: all-intra only for growing
// files, efficient long-GOP for normal record (so NVENC stays realtime and
// doesn't drop frames). All other codecs use their static arg set.
if (codec === 'hevc_nvenc') {
args.push(...hevcNvencArgs(framerate, growing));
} else {
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));
// Fragmented MOV/MP4 for direct S3 streaming (pipe:1 output — no seekable
// file on the worker disk). +frag_keyframe writes a moof/trun fragment per
// keyframe; +empty_moov puts a valid moov box at the start so the file is
// immediately parseable. Premiere Pro 25.x (2025) handles fragmented MOV
// natively. Growing-file masters use the same flags (written to SMB share).
if (fmt === 'mov' || fmt === 'mp4') {
args.push('-movflags', '+frag_keyframe+empty_moov+default_base_moof');
}
// 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 }) {
// ── Network sources via framecache (primary when FC_SLOT_ID is set) ──────
// node-agent starts net_ingest before the sidecar, which decodes the stream
// to raw UYVY422 and registers a framecache slot. We read from that slot via
// fc_pipe — same zero-copy path as SDI sources — enabling simultaneous
// growing + proxy + HLS from any network source.
if ((sourceType === 'srt' || sourceType === 'rtmp') && process.env.FC_SLOT_ID) {
const slotId = process.env.FC_SLOT_ID;
const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe';
const WAIT_MS = 60_000; /* network sources may take longer to connect */
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const fcFps = process.env.DELTACAST_FRAMERATE || '30000/1001';
console.log(`[framecache] net slot=${slotId} size=${fcSize} fps=${fcFps}`);
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
// Pause stdout immediately so frames don't fill the OS pipe buffer (and
// block fc_pipe's write()) in the window between spawn here and the
// .pipe(ffmpeg.stdin) attach later in start(). .pipe() auto-resumes.
fcPipeProcess.stdout.pause();
fcPipeProcess.stderr.on('data', chunk => {
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
});
fcPipeProcess.on('error', err =>
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`));
return {
inputArgs: [
// No -use_wallclock_as_timestamps — framecache delivers CFR frames
// at the original ingest rate; -framerate produces correct timestamps.
'-thread_queue_size', '512',
'-f', 'rawvideo',
'-pix_fmt', 'uyvy422',
'-video_size', fcSize,
'-framerate', fcFps,
'-i', 'pipe:0',
],
isNetwork: false, /* treat as raw source — no -map 0:v:0? needed */
bridgeProcess: fcPipeProcess,
audioFifo: null,
interlaced: false,
audioInputIndex: 0, /* network fc_pipe is video-only — no audio input */
_fcPipeProcess: fcPipeProcess,
};
}
// ── Legacy direct network paths (no framecache / net_ingest not running) ──
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 };
}
// ── Framecache path (primary for deltacast + blackmagic) ────────────────
//
// When FC_SLOT_ID is set in the sidecar env (injected by node-agent from
// the bridge's format JSON), we use the framecache shm ring buffer as the
// video source instead of named FIFOs.
//
// fc_pipe is a small C helper that opens the framecache slot as a consumer
// and writes raw UYVY422 frames to stdout. capture-manager spawns it and
// pipes its stdout to ffmpeg as a rawvideo input — same pattern as the
// existing FIFO path, but with zero-copy shm reads and independent per-
// consumer cursors. Multiple fc_pipe instances on the same slot each get
// their own cursor, enabling simultaneous growing + proxy + HLS from one
// SDI input without any frame splitting.
//
// Audio stays on the named FIFO path (audio fan-out via shm is a roadmap
// item).
//
// node-agent ALWAYS injects FC_SLOT_ID for SDI sidecars (deterministic
// `deltacast-<board>-<port>` / `decklink-<node>-<dev>`), so this is the sole
// SDI path. The old FC_SLOT_ID-absent legacy FIFO fallback was removed once
// framecache became mandatory on every capture node.
if ((sourceType === 'deltacast' || sourceType === 'sdi' || sourceType === 'blackmagic')
&& process.env.FC_SLOT_ID) {
const slotId = process.env.FC_SLOT_ID;
const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe';
const WAIT_MS = 30_000;
// Single-input AVI: fc_pipe muxes video+audio into ONE streaming AVI
// container on stdout. ffmpeg reads it as a SINGLE input (-f avi -i pipe:0),
// which eliminates the confirmed two-live-pipe deadlock (ffmpeg given a raw
// video pipe AND a separate live audio FIFO stalled forever probing input 0).
// No audio FIFO is created or used on this path anymore: audio rides inside
// the AVI as interleaved 01wb chunks, frame-coupled to each 00dc video chunk
// (both come from the SAME framecache ring entry in fc_pipe's read loop).
// Video dimensions and fps come from env vars injected by node-agent
// (populated from the bridge's format JSON on signal lock). fc_pipe also
// reads them from the slot header for the AVI header; these stay for logging.
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const fcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
const fcInterlaced = process.env.DELTACAST_INTERLACED === '1';
console.log(`[framecache] slot=${slotId} size=${fcSize} fps=${fcFps} mode=avi (single-input video+audio, frame-coupled)`);
// Spawn fc_pipe in AVI mode: for each ring entry it emits a 00dc video chunk
// followed by a 01wb audio chunk into one AVI byte stream on stdout. ffmpeg
// reads that single stream and maps 0:v / 0:a. Because video and its audio
// are interleaved from the same ring entry, audio can never drift from video.
// argv: <slot_id> <wait_ms> --avi
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS), '--avi'], {
stdio: ['ignore', 'pipe', 'pipe'],
});
// Pause until piped to ffmpeg (avoids OS pipe-buffer fill stall - see
// the network path above for the full rationale).
fcPipeProcess.stdout.pause();
fcPipeProcess.stderr.on('data', chunk => {
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
});
fcPipeProcess.on('error', err => {
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`);
});
return {
inputArgs: [
// fc_pipe stdout -> ffmpeg AVI input 0. ONE input carries both streams:
// 0:v = UYVY422 video (00dc chunks), 0:a = pcm_s16le audio (01wb chunks).
// The AVI demuxer reads the strf headers + the chunk stream with no index
// and no seeking, so streaming over a pipe is fine (RIFF/movi sizes are
// left as the streaming sentinel by fc_pipe).
'-thread_queue_size', '512',
'-f', 'avi',
// Optional fixed A/V trim (env AUDIO_OFFSET_MS); default empty = no shift.
// Applied as an input option so it shifts the AVI's audio relative to video.
...audioOffsetArgs(),
'-i', 'pipe:0',
],
isNetwork: false,
bridgeProcess: fcPipeProcess, /* capture-manager pipes this to ffmpeg stdin */
audioFifo: null, /* no separate audio FIFO on the AVI path */
interlaced: fcInterlaced,
audioInputIndex: 0, /* audio is inside the single AVI input (0:a) */
_fcPipeProcess: fcPipeProcess, /* stored for clean stop */
};
}
// 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 single-ffmpeg argv for a GROWING VC-3 / DNxHD master in MXF OP1a.
*
* KEY: ffmpeg's native MXF muxer writes a frame-wrapped OP1a whose BODY grows
* readably while still being written — proven on-node: the partial file opens
* as 'mxf' and decodes 0 errors at t=4s/6s mid-write, and finalizes with a
* valid Duration + footer on clean stop. This is exactly how vMix records
* growing VC-3 that Premiere imports live. So NO raw2bmx, NO FIFO orchestrator,
* NO footer-finalize ordering — one ffmpeg writes the MXF straight to the share.
*
* Valid VC-3 profiles at 1080p59.94 (8-bit 4:2:2, confirmed on-node):
* 220 Mbps -> classic DNxHD (essence VC3_1080p_1238), highest quality.
* 90 Mbps -> DNxHR-LB-class, lighter storage. Both grow + import in Premiere
* via the ffmpeg-direct MXF path (raw2bmx is NOT involved here, so
* the DNxHR-90 profile is fine even though raw2bmx can't parse it).
* Bitrate comes from `vc3Bitrate` ('220M' default | '90M'). On SIGINT ffmpeg
* flushes the MXF footer cleanly, so the normal SIGINT stop works here.
*/
_buildGrowingVc3Mxf({ inputArgs, framerate, audioChannels, outPath, audioMap = '0:a:0?', hlsDir = null, videoCodec = 'h264_nvenc', interlaced = false, vc3Bitrate = '90M' }) {
const ach = audioChannels ? Number(audioChannels) : 2;
const vb = (vc3Bitrate === '90M' || vc3Bitrate === '220M') ? vc3Bitrate : '90M';
const args = ['-y', '-hide_banner', '-loglevel', 'warning', '-stats', ...inputArgs];
// Deinterlace (SDI) then split: master VC-3 + optional HLS preview tap.
const filterComplex = hlsDir
? (interlaced ? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]' : '[0:v]split=2[vhi][vlo]')
: (interlaced ? '[0:v]yadif=mode=1:deint=1,split=1[vhi]' : '[0:v]split=1[vhi]');
args.push('-filter_complex', filterComplex);
// (a) VC-3/DNxHD master (8-bit 4:2:2) -> MXF OP1a, growing-readable.
// `-threads 16 -thread_type slice`: CRITICAL for the first ~10s. With ffmpeg's
// DEFAULT frame-threading, dnxhd buffers a long pipeline before output starts
// — it runs at ~0.27x for the first few seconds, so the fc_pipe ring overflows
// and ~344 startup frames are DROPPED (spotty audio+video for ~10s). Explicit
// slice threading makes dnxhd encode >= realtime from frame 1 (measured 1.3x
// at start vs 0.27x), eliminating the startup backlog and the dropped frames.
args.push('-map', '[vhi]',
'-c:v', 'dnxhd', '-threads', '32', '-thread_type', 'slice',
'-b:v', vb, '-pix_fmt', 'yuv422p',
'-r', framerate || '60000/1001',
'-map', audioMap, '-c:a', 'pcm_s24le', '-ar', '48000', '-ac', String(ach),
'-f', 'mxf', outPath);
// (b) optional H.264 HLS preview -> second output (keeps the UI monitor live).
if (hlsDir) {
args.push('-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`);
}
return args;
}
/**
* 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');
}
// Stop the idle confidence monitor BEFORE touching the FIFO. A second
// reader on the video FIFO halves the capture rate (~29 fps) and desyncs
// audio — so the monitor must fully release the FIFO before recording.
this.stopIdlePreview();
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.
// Read growing flags FRESH from env at record time — standby sidecars boot
// with GROWING_ENABLED=false and receive the real value per-session over
// /capture/start (capture.js sets process.env before this runs). The old
// module-level `const GROWING_ENABLED` / `GROWING_SMB_MOUNT` captured the
// empty boot values, so growing never engaged and every "growing" record
// silently produced HEVC/S3 instead of the VC-3/DNxHD MXF.
let growingActive = process.env.GROWING_ENABLED === 'true';
if (growingActive && growingSmbConfig().mount) {
if (!mountGrowingShare()) growingActive = false; // fall back to S3
}
// Growing master is always VC-3/DNxHD in MXF OP1a, written directly by
// ffmpeg's native MXF muxer (see _buildGrowingVc3Mxf), regardless of the
// recorder's configured container — so it gets a .mxf extension.
const _growCodec = growingActive ? growingCodec() : null;
const _growExt = _growCodec ? growingExtFor(_growCodec) : GROWING_EXT;
const growingPath = growingActive
? `${GROWING_PATH}/${projectId}/${clipName}.${_growExt}`
: 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 ? _growExt : (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;
this._sessionIdForBridge = sessionId;
const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false, audioInputIndex = 0,
} = await this._buildInputArgs({
sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey,
});
// ── Pre-roll + A/V alignment ─────────────────────────────────────────────
// The pre-roll drains the VIDEO pipe (fc_pipe) to discard unstable startup
// frames. In STANDBY the framecache slot is already warm, so there are no
// unstable frames — skip the video drain (draining only video while audio
// keeps buffering is exactly what offset the streams, giving "silent first
// second then clean").
if (bridgeProcess && !_standbyMode
&& (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) {
console.log(`[capture] pre-rolling: discarding ${PRE_ROLL_SECONDS}s of frames`);
bridgeProcess.stdout.on('data', () => {});
await new Promise(r => setTimeout(r, PRE_ROLL_SECONDS * 1000));
bridgeProcess.stdout.removeAllListeners('data');
console.log(`[capture] pre-roll complete.`);
}
// FLUSH STALE AUDIO immediately before ffmpeg opens the FIFO.
//
// With frame-coupled audio (FC_VERSION 2) fc_pipe only writes the audio FIFO
// once a reader attaches, and each audio chunk is bound to its video frame in
// the same ring entry — so there is normally NO stale standby backlog. This
// drain is retained as a harmless belt-and-suspenders: it reads whatever (if
// anything) is buffered and returns immediately on EAGAIN, guaranteeing the
// record ffmpeg attaches at the live edge. fc_pipe reattaches automatically
// if it briefly saw this drain as its reader.
if (audioFifo) {
try {
const fsSync = await import('node:fs');
const fd = fsSync.openSync(audioFifo, fsSync.constants.O_RDONLY | fsSync.constants.O_NONBLOCK);
const tmp = Buffer.allocUnsafe(1 << 20);
let drained = 0;
for (;;) {
let n = 0;
try { n = fsSync.readSync(fd, tmp, 0, tmp.length, null); }
catch (e) { if (e.code === 'EAGAIN') break; throw e; }
if (n <= 0) break;
drained += n;
}
fsSync.closeSync(fd);
console.log(`[capture] flushed ${drained} bytes of stale standby audio before record`);
} catch (e) {
console.warn(`[capture] audio FIFO pre-flush skipped: ${e.message}`);
}
}
const startedAt = new Date().toISOString();
const recordingStartedAt = Date.now();
// Audio input index is returned EXPLICITLY by _buildInputArgs (audioInputIndex)
// rather than guessed from sourceType/FC_SLOT_ID — that guess was wrong for
// the legacy deltacast FIFO path (which has audio at input 1 but no FC_SLOT_ID),
// silently dropping audio. Each return path now declares its own audio input:
// - deltacast/blackmagic via framecache: audio FIFO = input 1
// - legacy deltacast FIFO: audio FIFO = input 1
// - network (framecache or legacy) + DeckLink-backend SDI: audio in input 0
const audioMap = `${audioInputIndex}:a:0?`;
// Non-growing master: ffmpeg muxes the finalized MOV directly. Growing
// master: _buildGrowingVc3Mxf builds its own ffmpeg argv below, 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)
|| ((sourceType === 'blackmagic') && interlaced);
const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : [];
// Master output destination.
//
// - Growing-files on → the growing VC-3/DNxHD OP1a MXF is written directly
// to the SMB share by a single ffmpeg (see _buildGrowingVc3Mxf), which
// also taps the HLS preview.
//
// - Growing-files off → ffmpeg writes fragmented MOV to pipe:1 (stdout),
// which is piped directly into a multipart S3 upload. No local temp file,
// no worker disk consumed. Premiere Pro 25.x handles fragmented MOV natively.
const hiresOutput = growingPath ? growingPath : 'pipe:1';
// pipe:1 = ffmpeg stdout → S3 stream. bridgeProcess (fc_pipe) uses stdin.
const hiresStdio = bridgeProcess ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'];
// For SDI/framecache sources (including network via framecache) the live
// HLS preview is a SECOND OUTPUT of the hires ffmpeg.
const _viaFcPipeHls = !!process.env.FC_SLOT_ID;
let sdiHlsDir = null;
if ((sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic'
|| (_viaFcPipeHls && (sourceType === 'srt' || sourceType === 'rtmp')))
&& 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: VC-3 / DNxHD -> MXF OP1a (ffmpeg-native) ──────────
// Single ffmpeg, NO raw2bmx / NO FIFO orchestrator. Video from fc_pipe
// stdin (pipe:0). ffmpeg's MXF muxer writes a frame-wrapped OP1a whose body
// grows readably mid-write — USER-CONFIRMED: imports + grows live in Adobe
// Premiere (matches the vMix edit-while-record workflow). _growCodec is
// 'vc3_220' or 'vc3_90'; growingVc3Bitrate() maps it to the dnxhd -b:v.
// SIGINT flushes the MXF footer cleanly, so the standard SIGINT stop applies.
const vc3Args = this._buildGrowingVc3Mxf({
inputArgs, framerate, audioChannels,
outPath: growingPath, audioMap,
hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null,
videoCodec, interlaced: isInterlacedSource,
vc3Bitrate: growingVc3Bitrate(_growCodec),
});
console.log(`[capture] growing master via VC-3/DNxHD MXF (ffmpeg-native, ${growingVc3Bitrate(_growCodec)}); args=` + vc3Args.length);
hiresProcess = spawn('ffmpeg', vc3Args, {
stdio: bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'],
detached: true,
});
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
hiresProcess.stdin.on('error', (e) => {
if (e && e.code !== 'EPIPE') console.warn(`[capture] vc3 growing stdin error: ${e.message}`);
});
bridgeProcess.stdout.on('error', (e) => {
console.warn(`[capture] fc_pipe stdout error: ${e && e.message}`);
});
bridgeProcess.stdout.pipe(hiresProcess.stdin);
bridgeProcess.on('exit', () => {
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
});
}
} else {
// ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ──
let hiresArgs;
const isSdiLike = sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic';
// Network via framecache (fc_pipe) also produces its master + HLS as a
// single split ffmpeg, exactly like SDI — it reads pipe:0, not a URL.
const isNetFcPipe = !!process.env.FC_SLOT_ID && (sourceType === 'srt' || sourceType === 'rtmp');
if ((isSdiLike || isNetFcPipe) && this._assetIdForHls) {
const filterStr = isInterlacedSource
? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]'
: '[0:v]split=2[vhi][vlo]';
// Network fc_pipe is video-only (no audio input) — omit audio maps so
// ffmpeg doesn't fail trying to map a nonexistent audio stream.
const hasAudio = audioInputIndex >= 0 && !isNetFcPipe;
const masterAudioMap = hasAudio ? ['-map', audioMap] : [];
const masterAudioFilter = hasAudio
? ['-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0'] : [];
const hlsAudioMap = hasAudio ? ['-map', audioMap] : [];
const hlsAudioCodec = hasAudio
? ['-c:a', 'aac', '-b:a', '128k', '-ar', '44100'] : [];
hiresArgs = [
...inputArgs,
'-filter_complex', filterStr,
// Output 0 — master (fragmented MOV streamed to S3 via pipe:1)
'-map', '[vhi]', ...masterAudioMap,
...masterAudioFilter,
...hiresCodecArgs,
hiresOutput,
// Output 1 — low-latency H.264 HLS preview for the UI monitor
'-map', '[vlo]', ...hlsAudioMap,
...buildHlsVideoArgs(videoCodec, framerate),
...hlsAudioCodec,
'-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/framecache preview as 2nd output -> ' + sdiHlsDir);
} else {
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
}
hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
// When video comes from fc_pipe, pipe its stdout to ffmpeg stdin.
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
bridgeProcess.stdout.pipe(hiresProcess.stdin);
bridgeProcess.on('exit', () => {
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
});
}
}
// Growing: promotion worker handles S3 upload after stop.
// Non-growing: start streaming stdout directly to S3 now (multipart upload
// completes when ffmpeg exits and closes the pipe).
const processes = { hires: hiresProcess };
const uploads = {
hires: growingPath
? Promise.resolve({ growingPath })
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout),
};
// ── HLS tee for legacy network sources (live preview in the UI) ──────────
// When network sources come via framecache (FC_SLOT_ID set), HLS preview is
// handled as a 2nd ffmpeg output in the hires process above (sdiHlsDir path).
// This tee is only for the legacy direct-URL network path (no framecache).
let hlsProcess = null;
let hlsDir = null;
if (isNetwork && !process.env.FC_SLOT_ID && 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?',
...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] legacy-net 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.lastFrameAt = new Date().toISOString();
// Use ffmpeg's own rolling fps value — it is a short-window average
// computed by ffmpeg itself and correctly reflects the true encode rate.
// The previous frame/elapsed cumulative calculation dragged low during
// startup.
const ffmpegFps = parseFloat(m[2]);
if (ffmpegFps > 0) this.state.currentFps = Math.round(ffmpegFps * 100) / 100;
}
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.recordingStartedAt = Date.now();
this.state.currentSession = {
sessionId,
projectId,
binId,
clipName,
device,
sourceType,
sourceUrl,
assetId,
hiresKey,
proxyKey,
growingPath,
growingCodec: growingPath ? _growCodec : null, // growing codec: 'vc3_90' | 'vc3_220'
audioFifo,
startedAt,
duration: 0,
_fcPipeProcess: bridgeProcess || null, /* fc_pipe process, if framecache path used */
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();
}
// ── Idle confidence monitor ────────────────────────────────────────────
// A low-rate (1 fps) single-JPEG confidence snapshot for the recorder tile
// when the recorder is NOT actively recording.
//
// CRITICAL: this must NEVER read the video FIFO while a recording is active.
// A second continuous reader on the same /dev/shm/deltacast/video-N.fifo
// splits the frames between the two readers, halving the capture rate to
// ~29 fps (the root cause of the out-of-sync / fast-playback bug). So the
// monitor:
// 1. runs ONLY when this.state.recording === false
// 2. opens the FIFO, grabs ONE frame, scales to a small JPEG, exits
// 3. sleeps 1s, repeats — yielding the FIFO completely between grabs
// 4. is fully stopped the instant a recording starts (see start())
async startIdlePreview() {
if (this._previewTimer || this._previewProc) return; // already running
if (this.state.recording) return; // never run during an active recording
const sourceType = process.env.SOURCE_TYPE;
const recorderId = process.env.RECORDER_ID;
if (!recorderId || !['deltacast', 'sdi'].includes(sourceType)) return;
if (sourceType !== 'deltacast') return; // SDI/blackmagic snapshot TBD
const previewDir = `/live/preview-${recorderId}`;
try { await fs.promises.mkdir(previewDir, { recursive: true }); } catch (_) {}
const size = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
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`;
const outJpg = previewDir + '/frame.jpg';
const tmpJpg = previewDir + '/frame.tmp.jpg';
this._previewStop = false;
console.log('[preview] starting 1fps confidence monitor for', recorderId);
const grabOnce = () => new Promise((resolve) => {
// Never compete with an active recording.
if (this._previewStop || this.state.recording) return resolve();
// -frames:v 1 reads exactly ONE frame then exits, releasing the FIFO.
// Read-rate is capped by -readrate 1 so the single-frame read consumes
// ~1 frame worth of FIFO data, not a burst.
const ff = spawn('ffmpeg', [
'-y',
'-f', 'rawvideo', '-pix_fmt', 'uyvy422', '-s', size,
'-i', videoFifo,
'-frames:v', '1',
'-vf', 'scale=480:-2',
'-q:v', '5',
tmpJpg,
], { stdio: ['ignore', 'ignore', 'ignore'] });
this._previewProc = ff;
const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 4000);
ff.on('exit', () => {
clearTimeout(killTimer);
this._previewProc = null;
// Atomic-ish swap so the served frame is never half-written.
fs.rename(tmpJpg, outJpg, () => resolve());
});
ff.on('error', () => { clearTimeout(killTimer); this._previewProc = null; resolve(); });
});
const loop = async () => {
while (!this._previewStop) {
await grabOnce();
if (this._previewStop) break;
await new Promise(r => { this._previewTimer = setTimeout(r, 1000); });
}
};
loop();
}
stopIdlePreview() {
this._previewStop = true;
if (this._previewTimer) { clearTimeout(this._previewTimer); this._previewTimer = null; }
if (this._previewProc) {
try { this._previewProc.kill('SIGKILL'); } 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 (VC-3/DNxHD MXF): `processes.hires` is a single ffmpeg writing
// the OP1a directly. A plain SIGINT makes ffmpeg flush the MXF footer
// (Duration + index) cleanly, exactly like the non-growing MOV trailer.
// Awaiting it guarantees the finalized, valid MXF is on the share before
// the promotion worker uploads it. The footer flush of a long recording
// can take a moment, so the growing safety-net timeout 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 {
// The growing ffmpeg is spawned detached (its own process group) →
// SIGKILL the whole group; otherwise just the process.
if (isGrowing && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch (_) {} }
proc.kill('SIGKILL');
} catch (_) {}
finish();
}, finalizeTimeoutMs);
});
// Stop: a plain SIGINT flushes the container footer/trailer cleanly for both
// the non-growing master and the single-ffmpeg VC-3/DNxHD growing MXF writer.
if (processes.hires) processes.hires.kill('SIGINT');
if (processes.proxy) processes.proxy.kill('SIGINT');
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
if (currentSession._fcPipeProcess) {
try { currentSession._fcPipeProcess.kill('SIGTERM'); } 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 {
// Non-growing: S3 upload was streaming from ffmpeg stdout — it completes
// when ffmpeg exits and closes the pipe (waitExit above ensures that).
// Growing: promotion worker handles S3.
const uploadPromises = [];
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 = {};
this.state.recordingStartedAt = null;
// 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/<assetId> for the first seg-*.ts (bridge/ffmpeg warm-up)
// 2. ffmpeg -i <segment> -frames:v 1 -> scaled JPEG
// 3. upload JPEG to S3 at thumbnails/<assetId>.jpg (matches mam-api convention)
// 4. POST /assets/<assetId>/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 };