The previous sed/python in-place edits on the node broke capture: the hires stderr parser was written with literal 0x08 BACKSPACE bytes instead of regex word boundaries, so it never matched ffmpeg output. framesReceived stayed 0, the shutdown handler saw "no frames" and marked every asset as an error even though video was captured. The ffmpeg base args had also been changed to -progress pipe:2, whose key=value output puts frame= and fps= on separate lines and does not match a combined regex. Fixes: - Parser: single robust regex matching ffmpeg's classic -stats line (frame= and fps= together). No backspace bytes, no word boundaries. - ffmpeg base args back to -stats (drop -progress pipe:2). Growing-file (Premiere edit-while-record), per bmx thread 87ac5750 and Drastic/Softron edit-while-ingest docs: - raw2bmx clip type op1a -> rdd9 (Sony XDCAM / RDD-9, the flavour Premiere reads while growing) with --index-follows so the IndexTableSegment is written in the same partition as the essence it indexes (lets a reader re-scanning body partitions seek toward the record head). NOT --avid-gf (Avid OP-Atom, Media-Composer-only, needs a companion AAF). - dur-patch.py: overwrite header Duration=-1 to 0 immediately at clip-open (Premiere rejects -1 on import), then track the live frame count every 3s from the last body partition IndexTableSegment. Shipped as services/capture/dur-patch.py (/app/dur-patch.py in the image). Deployed to wild-dragon-capture:latest on zampp2 via overlay build. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
1204 lines
56 KiB
JavaScript
1204 lines
56 KiB
JavaScript
import { spawn, execFileSync } from 'child_process';
|
||
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs';
|
||
import { dirname } from 'node:path';
|
||
import { v4 as uuidv4 } from 'uuid';
|
||
import { createUploadStream } from './s3/client.js';
|
||
|
||
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 <interval>` writes a NEW body partition PLUS
|
||
// a NEW IndexTableSegment (carrying an updated IndexDuration) at the
|
||
// interval. The recorded duration is therefore readable — and INCREASES —
|
||
// from the header+index ALONE while the file is still being written, no
|
||
// footer needed. Verified by snapshotting the growing file mid-write and
|
||
// parsing the IndexTableSegment IndexDuration (local tag 0x3F0C):
|
||
// T= 3s: 7 partitions, max IndexDuration = 43 frames
|
||
// T= 8s: 17 partitions, max IndexDuration = 193 frames
|
||
// T=15s: 31 partitions, max IndexDuration = 403 frames
|
||
// The recorded frame count grows monotonically, lagging the record head by
|
||
// ~one partition interval — exactly the editable-head behaviour Premiere's
|
||
// growing-MXF reader consumes. A mid-write snapshot also decodes cleanly
|
||
// (mpeg2video 1920x1080 + 2×PCM, ffmpeg decode exit 0). Contrast with the
|
||
// ffmpeg `-f mxf` path (attempt #5): duration=N/A until close.
|
||
// * Adobe OFFICIALLY recommends MXF for growing-file workflows; XDCAM HD422
|
||
// (MPEG-2 422 in MXF OP1a) + PCM is read by Premiere's built-in MXF reader
|
||
// with no plugin and is the broadcast-standard growing acquisition format.
|
||
//
|
||
// Pipeline (single SDI read — DeckLink cannot be opened twice):
|
||
// ffmpeg decklink → yadif → split →
|
||
// (a) MPEG-2 422 elementary VIDEO → named FIFO ┐
|
||
// (b) PCM s16le AUDIO → named FIFO ├→ raw2bmx -t op1a
|
||
// (c) H.264 HLS preview (unchanged, keeps monitor live)
|
||
// raw2bmx reads the two essence FIFOs and writes the growing OP1a MXF to the
|
||
// CIFS share. On stop, ffmpeg is stopped cleanly so raw2bmx gets EOF and
|
||
// finalizes the footer; we await raw2bmx exit before reporting complete.
|
||
//
|
||
// Audio: PCM s16le — the native, broadcast-standard MXF audio mapping
|
||
// Premiere's MXF reader expects (NOT AAC).
|
||
//
|
||
// HONEST CAVEAT (cannot be verified without real Premiere on the workstation):
|
||
// the growing IndexDuration / body-partition structure is PROVEN above and
|
||
// matches Adobe's documented growing-MXF requirement — but only the user
|
||
// opening the growing .mxf in actual Premiere Pro (with "Automatically refresh
|
||
// growing files" enabled in Preferences > Media) can confirm the end-to-end
|
||
// edit-while-record.
|
||
//
|
||
// ── ffmpeg elementary-essence args (input to the FIFOs) ───────────────────
|
||
// (a) MPEG-2 422, 8-bit 4:2:2 (Premiere-native XDCAM HD422). `-dc 10` + the CBR
|
||
// bitrate (operator target, default 50 Mbps) match XDCAM HD422 essence. `-g 15`
|
||
// keeps a short GOP. Muxed to a raw `mpeg2video` elementary stream (no
|
||
// container) so raw2bmx ingests it via --mpeg2lg_*.
|
||
const GROWING_VIDEO_ELEMENTARY_ARGS = [
|
||
'-c:v', 'mpeg2video', '-pix_fmt', 'yuv422p',
|
||
'-dc', '10', '-g', '15', '-bf', '2',
|
||
];
|
||
const GROWING_DEFAULT_BITRATE = '50M';
|
||
const GROWING_EXT = 'mxf';
|
||
// Video essence partition interval (frames). raw2bmx starts a new body partition
|
||
// + IndexTableSegment every PART_INTERVAL frames; this is the granularity at
|
||
// which the growing file's recorded duration advances. ~1s at 25/29.97 fps.
|
||
const GROWING_PART_INTERVAL_FRAMES = 30;
|
||
|
||
// Map the recorder's resolution/fps to (1) the raw2bmx MPEG-2 Long GOP essence
|
||
// input flag and (2) the ffmpeg edit-rate (`-r`). raw2bmx needs the correct
|
||
// raster flag so the essence is wrapped as the right XDCAM HD422 variant; an
|
||
// 1080i59.94 default is used when the recorder fields are absent (the most
|
||
// common SDI broadcast raster). Returns:
|
||
// { rawFlag, frameRate, ffRate }
|
||
// where rawFlag is e.g. '--mpeg2lg_422p_hl_1080i', frameRate is the raw2bmx
|
||
// `-f` value (e.g. '30000/1001'), and ffRate is the ffmpeg `-r` value.
|
||
//
|
||
// NOTE: the exact interlaced-vs-progressive raster and the fps for a real
|
||
// DeckLink SDI feed can only be confirmed against the live signal. This derives
|
||
// a sensible value from the recorder's configured resolution/framerate; if those
|
||
// are absent or ambiguous it defaults to 1080i59.94. A live DeckLink confirm of
|
||
// the actual SDI raster/fps is advised before production use (see report).
|
||
function deriveGrowingRaster(resolution, framerate) {
|
||
// Normalise fps. Accept '59.94', '60000/1001', '25', '50', '30', '29.97'…
|
||
let fpsNum = null;
|
||
const fr = (framerate == null) ? '' : String(framerate).trim();
|
||
if (/^\d+\/\d+$/.test(fr)) {
|
||
const [n, d] = fr.split('/').map(Number);
|
||
if (d) fpsNum = n / d;
|
||
} else if (fr && fr !== 'native') {
|
||
const f = Number.parseFloat(fr);
|
||
if (Number.isFinite(f)) fpsNum = f;
|
||
}
|
||
|
||
// Resolution → height + scan. Accept '1920x1080', '1080i', '1080p', '720p',
|
||
// '720', '576i', etc.
|
||
const res = (resolution == null) ? '' : String(resolution).trim().toLowerCase();
|
||
let height = null;
|
||
let scan = null; // 'i' | 'p' | null
|
||
const mDim = res.match(/(\d{3,4})x(\d{3,4})/);
|
||
if (mDim) height = parseInt(mDim[2], 10);
|
||
const mH = res.match(/(\d{3,4})\s*([ip])/);
|
||
if (mH) { height = parseInt(mH[1], 10); scan = mH[2]; }
|
||
if (height == null) {
|
||
const only = res.match(/\b(2160|1080|720|576|480)\b/);
|
||
if (only) height = parseInt(only[1], 10);
|
||
}
|
||
if (height == null) height = 1080; // default raster
|
||
|
||
// ffmpeg rate + raw2bmx rate strings for the common broadcast rates.
|
||
function rates(fps) {
|
||
if (fps == null) return { ff: '30000/1001', raw: '30000/1001' }; // 1080i59.94 default
|
||
if (Math.abs(fps - 59.94) < 0.2 || Math.abs(fps - 29.97) < 0.05)
|
||
return { ff: '30000/1001', raw: '30000/1001' };
|
||
if (Math.abs(fps - 60) < 0.05) return { ff: '60', raw: '60' };
|
||
if (Math.abs(fps - 50) < 0.05) return { ff: '25', raw: '25' }; // 1080i50 → 25 fps frames
|
||
if (Math.abs(fps - 25) < 0.05) return { ff: '25', raw: '25' };
|
||
if (Math.abs(fps - 24) < 0.2) return { ff: '24000/1001', raw: '24000/1001' };
|
||
if (Math.abs(fps - 30) < 0.05) return { ff: '30', raw: '30' };
|
||
return { ff: String(fps), raw: String(fps) };
|
||
}
|
||
|
||
// Default scan: 1080 → interlaced (broadcast SDI default), 720/below → p.
|
||
if (scan == null) scan = (height >= 1080) ? 'i' : 'p';
|
||
const r = rates(fpsNum);
|
||
|
||
let rawFlag;
|
||
if (height >= 1080) {
|
||
rawFlag = (scan === 'p') ? '--mpeg2lg_422p_hl_1080p' : '--mpeg2lg_422p_hl_1080i';
|
||
} else if (height >= 720) {
|
||
rawFlag = '--mpeg2lg_422p_hl_720p'; // 720 is always progressive
|
||
if (fpsNum == null) { r.ff = '60000/1001'; r.raw = '60000/1001'; }
|
||
} else {
|
||
rawFlag = '--mpeg2lg_422p_ml_576i'; // SD 576i (PAL); 25 fps
|
||
r.ff = '25'; r.raw = '25';
|
||
}
|
||
|
||
return { rawFlag, frameRate: r.raw, ffRate: r.ff };
|
||
}
|
||
|
||
// ── Source-backend abstraction (issue #168) ──────────────────────────────
|
||
// The capture input was historically hard-wired to a single `-f decklink -i …`
|
||
// construction. To allow other SDI capture cards (Deltacast, AJA) to be added
|
||
// later without touching the encode/output/HLS pipeline, the per-backend FFmpeg
|
||
// INPUT-arg construction now lives behind this map. Each backend exposes:
|
||
//
|
||
// buildInput(ctx) -> { inputArgs, isNetwork } (may be async)
|
||
//
|
||
// where `ctx` carries the resolved recorder fields the backend needs (device).
|
||
// The rest of capture-manager consumes the returned `inputArgs` unchanged, so
|
||
// adding a backend is purely additive.
|
||
//
|
||
// IMPORTANT: `blackmagic` is a behaviour-preserving extraction of the previous
|
||
// default DeckLink path — for an existing DeckLink recorder the produced ffmpeg
|
||
// input args are byte-for-byte identical to the pre-refactor code. The
|
||
// `deltacast`/`aja` entries are stubs that throw until the hardware/SDK plumbing
|
||
// lands.
|
||
const sourceBackends = {
|
||
// BlackMagic DeckLink over SDI (the only backend implemented today).
|
||
// device may be an integer index (0-based) or a full device name string.
|
||
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
|
||
// Map integer index -> name using ffmpeg -sources decklink at runtime.
|
||
//
|
||
// ffmpeg -sources decklink output format:
|
||
// Auto-detected sources for decklink:
|
||
// DeckLink Duo 2
|
||
// DeckLink Duo 2 (2)
|
||
// Lines containing device names start with whitespace; the header line
|
||
// starts with a non-space character. Previous code used a v4l2-style
|
||
// hex-address regex that never matched DeckLink output → index 1+ always
|
||
// fell through to a wrong fallback, producing black output from port 2+.
|
||
blackmagic: {
|
||
async buildInput({ device }) {
|
||
let deckLinkName = String(device);
|
||
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
|
||
const idx = parseInt(device, 10);
|
||
try {
|
||
const { execSync } = await import('child_process');
|
||
const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
|
||
const names = [];
|
||
for (const line of out.split('\n')) {
|
||
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
|
||
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
|
||
if (m) names.push(m[1]);
|
||
}
|
||
if (names[idx]) {
|
||
deckLinkName = names[idx];
|
||
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
|
||
} else {
|
||
// Fallback: cannot determine model name without enumeration.
|
||
// Log a warning — operator should check the detected device list.
|
||
console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`);
|
||
deckLinkName = `DeckLink (${idx})`;
|
||
}
|
||
} catch (err) {
|
||
console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`);
|
||
// Pass the numeric index directly; some ffmpeg builds accept it.
|
||
deckLinkName = String(device);
|
||
}
|
||
}
|
||
return {
|
||
inputArgs: ['-f', 'decklink', '-i', deckLinkName],
|
||
isNetwork: false,
|
||
};
|
||
},
|
||
},
|
||
|
||
// Stubs — hardware/SDK plumbing not yet implemented. These throw clearly so a
|
||
// misconfigured recorder fails fast instead of silently falling back to the
|
||
// wrong card.
|
||
deltacast: {
|
||
buildInput() {
|
||
throw new Error('deltacast backend not yet implemented — requires hardware');
|
||
},
|
||
},
|
||
aja: {
|
||
buildInput() {
|
||
throw new Error('aja backend not yet implemented — requires hardware');
|
||
},
|
||
},
|
||
};
|
||
|
||
function buildEncodeArgs({
|
||
codec, videoBitrate, framerate,
|
||
audioCodec, audioBitrate, audioChannels,
|
||
container, isNetwork, isProxy = false,
|
||
growing = false,
|
||
}) {
|
||
// NOTE: the growing master is NOT muxed by ffmpeg any more — raw2bmx writes
|
||
// the growing OP1a MXF from elementary essence FIFOs (see start()). The
|
||
// growing ffmpeg command (elementary MPEG-2 422 video + PCM audio to FIFOs,
|
||
// plus the HLS preview) is constructed directly in start(), so buildEncodeArgs
|
||
// is no longer called with growing=true. The `growing` param is retained for
|
||
// call-site compatibility; if ever set, fall through to the finalized path so
|
||
// we never silently produce a wrong file.
|
||
|
||
const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
|
||
const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
|
||
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
|
||
|
||
const args = [];
|
||
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
|
||
|
||
args.push(...v.args);
|
||
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
|
||
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
|
||
if (framerate && framerate !== 'native') args.push('-r', framerate);
|
||
|
||
args.push(...a.args);
|
||
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
|
||
if (audioChannels) args.push('-ac', String(audioChannels));
|
||
|
||
// moov-atom placement is the difference between a Premiere-openable master and
|
||
// a "file cannot be opened" error.
|
||
//
|
||
// Finalized masters (the S3-piped recording that stops cleanly) must NOT be
|
||
// fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic
|
||
// stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV
|
||
// (moof/trun, empty sample tables) makes Premiere report "file cannot be
|
||
// opened." We write a clean, non-fragmented MOV instead. `+faststart` puts the
|
||
// moov before mdat on the second pass so the file is instantly
|
||
// seekable/streamable too.
|
||
if (fmt === 'mov' || fmt === 'mp4') {
|
||
args.push('-movflags', '+faststart');
|
||
}
|
||
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
|
||
args.push('-f', fmt);
|
||
|
||
return args;
|
||
}
|
||
|
||
class CaptureManager {
|
||
constructor() {
|
||
this.state = {
|
||
recording: false,
|
||
sessionId: null,
|
||
processes: {},
|
||
currentSession: {},
|
||
framesReceived: 0,
|
||
currentFps: 0,
|
||
lastFrameAt: null,
|
||
lastError: null,
|
||
};
|
||
}
|
||
|
||
/**
|
||
* Build FFmpeg input arguments based on source type.
|
||
* Returns { inputArgs, isNetwork }
|
||
* @private
|
||
*/
|
||
async _buildInputArgs({ sourceType, sourceBackend = 'blackmagic', device, sourceUrl, listen, listenPort, streamKey }) {
|
||
if (sourceType === 'srt') {
|
||
let url;
|
||
if (listen) {
|
||
const port = listenPort || 9000;
|
||
url = `srt://0.0.0.0:${port}?mode=listener`;
|
||
} else {
|
||
url = sourceUrl;
|
||
if (!url.includes('mode=')) {
|
||
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
||
}
|
||
}
|
||
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
|
||
}
|
||
|
||
if (sourceType === 'rtmp') {
|
||
if (listen) {
|
||
const port = listenPort || 1935;
|
||
const key = streamKey || 'stream';
|
||
return {
|
||
inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
|
||
isNetwork: true,
|
||
};
|
||
}
|
||
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
|
||
}
|
||
|
||
// Deltacast SDI via VideoMaster SDK FFmpeg plugin.
|
||
// FFmpeg input format is 'deltacast', device address is 'deltacast://<index>'.
|
||
// When the physical device is absent (/dev/deltacast<N> missing), fall back
|
||
// to a lavfi test card so development and integration testing work without hardware.
|
||
if (sourceType === 'deltacast') {
|
||
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
|
||
? parseInt(device, 10)
|
||
: 0;
|
||
const { existsSync } = await import('node:fs');
|
||
const deviceNode = `/dev/deltacast${idx}`;
|
||
if (existsSync(deviceNode)) {
|
||
console.log(`[capture] Deltacast index ${idx} → ${deviceNode} (hardware)`);
|
||
return {
|
||
inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`],
|
||
isNetwork: false,
|
||
};
|
||
} else {
|
||
// No hardware — lavfi test card with port label + timecode burn-in.
|
||
// Matches the deltacast-sdi-recorder standalone app fallback exactly so
|
||
// recorded files look right in the MAM library during dev.
|
||
console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`);
|
||
const testSrc = [
|
||
`testsrc2=size=1920x1080:rate=30`,
|
||
`drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`,
|
||
`drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`,
|
||
].join(',');
|
||
return {
|
||
inputArgs: [
|
||
'-f', 'lavfi', '-i', testSrc,
|
||
'-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
|
||
'-map', '0:v:0', '-map', '1:a:0',
|
||
],
|
||
isNetwork: false,
|
||
};
|
||
}
|
||
}
|
||
|
||
// 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 }) {
|
||
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.
|
||
const ffArgs = [
|
||
...inputArgs,
|
||
'-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]',
|
||
// (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', '0:a:0?',
|
||
'-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', '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`,
|
||
];
|
||
}
|
||
// @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>&-
|
||
# Start the MXF header duration patcher (services/capture/dur-patch.py, shipped
|
||
# to /app/dur-patch.py by the image build). It overwrites the header Duration=-1
|
||
# fields with 0 immediately (Premiere rejects -1 on import; bmx thread 87ac5750)
|
||
# and then with the live frame count every 3s, parsed from the last body
|
||
# partition IndexTableSegment, so the clip grows in Premiere.
|
||
python3 -u /app/dur-patch.py "$OUT" >&2 &
|
||
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,
|
||
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();
|
||
|
||
const { inputArgs, isNetwork } = await this._buildInputArgs({
|
||
sourceType, sourceBackend, device, sourceUrl, listen, listenPort, streamKey,
|
||
});
|
||
|
||
// 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 sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];
|
||
|
||
// Master output destination (NON-growing path only).
|
||
//
|
||
// - Growing-files on → the growing OP1a MXF is written directly to the SMB
|
||
// share by raw2bmx (see the orchestrator below); ffmpeg only produces the
|
||
// elementary essence FIFOs + HLS preview. `localMasterPath`/`hiresOutput`
|
||
// are unused in this case (the master path is `growingPath`).
|
||
//
|
||
// - Growing-files off → ffmpeg writes the MOV master to a LOCAL SEEKABLE
|
||
// temp file, then we upload to S3 on stop. We must NOT pipe the MOV muxer
|
||
// to S3 directly: the MOV/MP4 muxer cannot write to a non-seekable pipe
|
||
// without `empty_moov`, and an empty_moov/fragmented MOV is exactly what
|
||
// makes Adobe Premiere report "file cannot be opened" (no classic
|
||
// stco/stsz sample tables — samples live in moof/trun). A seekable file
|
||
// lets ffmpeg write a single contiguous moov with full sample tables and
|
||
// `+faststart` moves it to the front, producing a Premiere-native master.
|
||
const localMasterPath = growingPath
|
||
? null
|
||
: `/tmp/capture/${sessionId}.${hiresExt}`;
|
||
if (localMasterPath) {
|
||
try { mkdirSync(dirname(localMasterPath), { recursive: true }); }
|
||
catch (err) { console.error('[capture] could not create temp master dir:', err.message); }
|
||
}
|
||
const hiresOutput = localMasterPath;
|
||
const hiresStdio = ['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' && 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') ? sdiHlsDir : null,
|
||
videoCodec,
|
||
});
|
||
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' && 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', '0:a:0?',
|
||
...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', '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', 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.
|
||
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,
|
||
hiresKey,
|
||
proxyKey,
|
||
growingPath,
|
||
localMasterPath,
|
||
startedAt,
|
||
duration: 0,
|
||
uploads,
|
||
codecs: {
|
||
videoCodec, videoBitrate, framerate,
|
||
audioCodec, audioBitrate, audioChannels, container,
|
||
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
|
||
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
|
||
},
|
||
};
|
||
|
||
return this._formatSessionResponse();
|
||
}
|
||
|
||
async stop(sessionId) {
|
||
if (!this.state.recording || this.state.sessionId !== sessionId) {
|
||
throw new Error('No active capture session or session ID mismatch');
|
||
}
|
||
|
||
const { processes, currentSession } = this.state;
|
||
|
||
const isGrowing = !!currentSession.growingPath;
|
||
|
||
// Send SIGINT and WAIT for the master writer to exit cleanly.
|
||
// - Non-growing: SIGINT flushes ffmpeg's MOV trailer (the moov atom with
|
||
// full sample tables). Uploading before finalize → "moov atom not found".
|
||
// - Growing: `processes.hires` is the bash ORCHESTRATOR (detached group
|
||
// leader). SIGINT hits its trap, which forwards SIGINT to ffmpeg; ffmpeg
|
||
// stops → raw2bmx gets EOF → raw2bmx writes the OP1a FOOTER and exits;
|
||
// only then does the orchestrator exit. Awaiting it guarantees the
|
||
// finalized, valid MXF is on the share before the promotion worker
|
||
// uploads it. raw2bmx footer finalize of a long recording can take longer
|
||
// than a MOV trailer flush, so the growing safety-net is more generous.
|
||
const finalizeTimeoutMs = isGrowing ? 60000 : 15000;
|
||
const waitExit = (proc) => new Promise((resolve) => {
|
||
if (!proc || proc.exitCode !== null || proc.signalCode !== null) return resolve();
|
||
let done = false;
|
||
const finish = () => { if (!done) { done = true; resolve(); } };
|
||
proc.once('exit', finish);
|
||
// Safety net: don't hang stop() forever if the writer refuses to exit.
|
||
setTimeout(() => {
|
||
try {
|
||
// Detached orchestrator → kill the whole process group (ffmpeg +
|
||
// raw2bmx + bash); otherwise just the process.
|
||
if (isGrowing && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch (_) {} }
|
||
proc.kill('SIGKILL');
|
||
} catch (_) {}
|
||
finish();
|
||
}, finalizeTimeoutMs);
|
||
});
|
||
|
||
if (processes.hires) processes.hires.kill('SIGINT');
|
||
if (processes.proxy) processes.proxy.kill('SIGINT');
|
||
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
||
|
||
// 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);
|
||
}
|
||
|
||
const stoppedAt = new Date().toISOString();
|
||
const startTime = new Date(currentSession.startedAt);
|
||
const stopTime = new Date(stoppedAt);
|
||
const duration = Math.round((stopTime - startTime) / 1000);
|
||
|
||
this.state.recording = false;
|
||
this.state.sessionId = null;
|
||
this.state.processes = {};
|
||
|
||
// No frames received → the upload (if any) produced a 0-byte object.
|
||
// Surface that so the shutdown handler can mark the asset as 'error'
|
||
// instead of posting a broken hi-res key downstream.
|
||
const framesReceived = this.state.framesReceived;
|
||
|
||
return {
|
||
sessionId,
|
||
projectId: currentSession.projectId,
|
||
binId: currentSession.binId,
|
||
clipName: currentSession.clipName,
|
||
sourceType: currentSession.sourceType,
|
||
hiresKey: currentSession.hiresKey,
|
||
proxyKey: currentSession.proxyKey,
|
||
growingPath: currentSession.growingPath || null,
|
||
startedAt: currentSession.startedAt,
|
||
stoppedAt,
|
||
duration,
|
||
framesReceived,
|
||
empty: framesReceived === 0,
|
||
};
|
||
}
|
||
|
||
getStatus() {
|
||
if (!this.state.recording) return { recording: false };
|
||
|
||
const startTime = new Date(this.state.currentSession.startedAt);
|
||
const now = new Date();
|
||
const duration = Math.round((now - startTime) / 1000);
|
||
|
||
const lastFrameAt = this.state.lastFrameAt;
|
||
const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
|
||
let signal = 'connecting';
|
||
if (this.state.framesReceived > 0) {
|
||
signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
|
||
} else if (this.state.lastError) {
|
||
signal = 'error';
|
||
}
|
||
return {
|
||
recording: true,
|
||
sessionId: this.state.sessionId,
|
||
sourceType: this.state.currentSession.sourceType,
|
||
device: this.state.currentSession.device,
|
||
clipName: this.state.currentSession.clipName,
|
||
projectId: this.state.currentSession.projectId,
|
||
binId: this.state.currentSession.binId,
|
||
duration,
|
||
startedAt: this.state.currentSession.startedAt,
|
||
signal,
|
||
framesReceived: this.state.framesReceived,
|
||
currentFps: this.state.currentFps,
|
||
lastFrameAt,
|
||
msSinceFrame,
|
||
lastError: this.state.lastError,
|
||
codecs: this.state.currentSession.codecs,
|
||
};
|
||
}
|
||
|
||
_formatSessionResponse() {
|
||
const { currentSession, sessionId } = this.state;
|
||
return {
|
||
sessionId,
|
||
projectId: currentSession.projectId,
|
||
binId: currentSession.binId,
|
||
clipName: currentSession.clipName,
|
||
device: currentSession.device,
|
||
sourceType: currentSession.sourceType,
|
||
hiresKey: currentSession.hiresKey,
|
||
proxyKey: currentSession.proxyKey,
|
||
startedAt: currentSession.startedAt,
|
||
codecs: currentSession.codecs,
|
||
};
|
||
}
|
||
}
|
||
|
||
export default new CaptureManager();
|
||
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT, sourceBackends };
|