On-node empirical testing of this bmx v1.6 build showed that raw2bmx's
rdd9 writer with --part already maintains a live, correct header Duration
as the file grows: ffprobe reads a growing duration mid-write (e.g. 2.04s
of a 10s clip while still recording), and the structural-metadata
Duration fields (tags 02020008 / 30020008) hold the real frame count
(0x33 = 51), not -1.
The dur-patch.py added in the previous commit searched the header for
Duration=-1 (0xFF*8) and found 0 fields on rdd9 ("[dur-patch] 0 Duration
fields"), so it was a no-op. Worse, opening the MXF r+b to patch it while
raw2bmx appends over CIFS is a concurrency hazard. Remove it entirely and
rely on raw2bmx's native growing Duration. rdd9 + --index-follows remains
the Premiere-recommended growing flavour (Sony XDCAM essence, index in the
essence partition).
Verified on-node (ffprobe/byte-probe). Live edit-while-record in Premiere
itself still requires user confirmation.
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>&-
|
||
# No header-duration patcher is needed. In this bmx v1.6 build, raw2bmx's rdd9
|
||
# writer with --part maintains a live, correct header Duration as the file grows
|
||
# (verified on-node: ffprobe reads a growing duration mid-write, e.g. 2.04s of a
|
||
# 10s clip while still recording). A patcher (the earlier dur-patch.py) was a
|
||
# no-op here — it searched for Duration=-1, which rdd9 never writes — and opening
|
||
# the file r+b while raw2bmx appends over CIFS only adds concurrency risk.
|
||
PATCHPID=
|
||
# Wait for ffmpeg (source end), then for raw2bmx to finalize the footer.
|
||
wait "$FFPID"; FFRC=$?
|
||
wait "$BMXPID"; BMXRC=$?
|
||
echo "[grow] ffmpeg rc=$FFRC raw2bmx rc=$BMXRC out=$OUT" >&2
|
||
exit "$BMXRC"
|
||
`;
|
||
return ['-c', script];
|
||
}
|
||
|
||
/**
|
||
* Start a new capture session.
|
||
*
|
||
* Codec parameters all have sensible defaults so legacy callers (no codec
|
||
* args) still produce ProRes HQ master + H.264 proxy.
|
||
*/
|
||
async start({
|
||
assetId,
|
||
projectId,
|
||
binId,
|
||
clipName,
|
||
device,
|
||
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 };
|