dragonflight/services/capture/src/capture-manager.js

1626 lines
76 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

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

import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
const PRE_ROLL_SECONDS = parseInt(process.env.PRE_ROLL_SECONDS || '5', 10);
// Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-recorder via `GROWING_ENABLED=true` 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 = '25M';
const GROWING_EXT = 'mxf';
// Video essence partition interval (frames). raw2bmx starts a new body partition
// + IndexTableSegment every PART_INTERVAL frames; this is the granularity at
// which the growing file's recorded duration advances. ~1s at 25/29.97 fps.
const GROWING_PART_INTERVAL_FRAMES = 30;
// Map the recorder's resolution/fps to (1) the raw2bmx MPEG-2 Long GOP essence
// input flag and (2) the ffmpeg edit-rate (`-r`). raw2bmx needs the correct
// raster flag so the essence is wrapped as the right XDCAM HD422 variant; an
// 1080i59.94 default is used when the recorder fields are absent (the most
// common SDI broadcast raster). Returns:
// { rawFlag, frameRate, ffRate }
// where rawFlag is e.g. '--mpeg2lg_422p_hl_1080i', frameRate is the raw2bmx
// `-f` value (e.g. '30000/1001'), and ffRate is the ffmpeg `-r` value.
//
// NOTE: the exact interlaced-vs-progressive raster and the fps for a real
// DeckLink SDI feed can only be confirmed against the live signal. This derives
// a sensible value from the recorder's configured resolution/framerate; if those
// are absent or ambiguous it defaults to 1080i59.94. A live DeckLink confirm of
// the actual SDI raster/fps is advised before production use (see report).
function deriveGrowingRaster(resolution, framerate, scanHint = null) {
// 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.
// scanHint ('p'/'i') overrides this default so progressive Deltacast captures
// are wrapped as progressive MXFs.
if (scan == null) scan = scanHint || ((height >= 1080) ? 'i' : 'p');
const r = rates(fpsNum);
let rawFlag;
if (height >= 1080) {
rawFlag = (scan === 'p') ? '--mpeg2lg_422p_hl_1080p' : '--mpeg2lg_422p_hl_1080i';
} else if (height >= 720) {
rawFlag = '--mpeg2lg_422p_hl_720p'; // 720 is always progressive
if (fpsNum == null) { r.ff = '60000/1001'; r.raw = '60000/1001'; }
} else {
rawFlag = '--mpeg2lg_422p_ml_576i'; // SD 576i (PAL); 25 fps
r.ff = '25'; r.raw = '25';
}
return { rawFlag, frameRate: r.raw, ffRate: r.ff };
}
// ── Source-backend abstraction (issue #168) ──────────────────────────────
// The capture input was historically hard-wired to a single `-f decklink -i …`
// construction. To allow other SDI capture cards (Deltacast, AJA) to be added
// later without touching the encode/output/HLS pipeline, the per-backend FFmpeg
// INPUT-arg construction now lives behind this map. Each backend exposes:
//
// buildInput(ctx) -> { inputArgs, isNetwork } (may be async)
//
// where `ctx` carries the resolved recorder fields the backend needs (device).
// The rest of capture-manager consumes the returned `inputArgs` unchanged, so
// adding a backend is purely additive.
//
// IMPORTANT: `blackmagic` is a behaviour-preserving extraction of the previous
// default DeckLink path — for an existing DeckLink recorder the produced ffmpeg
// input args are byte-for-byte identical to the pre-refactor code. The
// `deltacast`/`aja` entries are stubs that throw until the hardware/SDK plumbing
// lands.
const sourceBackends = {
// BlackMagic DeckLink over SDI (the only backend implemented today).
// device may be an integer index (0-based) or a full device name string.
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
// Map integer index -> name using ffmpeg -sources decklink at runtime.
//
// ffmpeg -sources decklink output format:
// Auto-detected sources for decklink:
// DeckLink Duo 2
// DeckLink Duo 2 (2)
// Lines containing device names start with whitespace; the header line
// starts with a non-space character. Previous code used a v4l2-style
// hex-address regex that never matched DeckLink output → index 1+ always
// fell through to a wrong fallback, producing black output from port 2+.
blackmagic: {
async buildInput({ device }) {
let deckLinkName = String(device);
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
const idx = parseInt(device, 10);
try {
const { execSync } = await import('child_process');
const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
const names = [];
for (const line of out.split('\n')) {
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
if (m) names.push(m[1]);
}
if (names[idx]) {
deckLinkName = names[idx];
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
} else {
// Fallback: cannot determine model name without enumeration.
// Log a warning — operator should check the detected device list.
console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`);
deckLinkName = `DeckLink (${idx})`;
}
} catch (err) {
console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`);
// Pass the numeric index directly; some ffmpeg builds accept it.
deckLinkName = String(device);
}
}
return {
inputArgs: ['-f', 'decklink', '-i', deckLinkName],
isNetwork: false,
};
},
},
deltacast: {
// Unused stub — deltacast capture uses sourceType='deltacast' path in
// _buildInputArgs, not the sourceBackends map.
buildInput() {
throw new Error('deltacast: use sourceType="deltacast" not sourceBackend');
},
},
aja: {
buildInput() {
throw new Error('aja backend not yet implemented — requires hardware');
},
},
};
function buildEncodeArgs({
codec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels,
container, isNetwork, isProxy = false,
growing = false,
}) {
// NOTE: the growing master is NOT muxed by ffmpeg any more — raw2bmx writes
// the growing OP1a MXF from elementary essence FIFOs (see start()). The
// growing ffmpeg command (elementary MPEG-2 422 video + PCM audio to FIFOs,
// plus the HLS preview) is constructed directly in start(), so buildEncodeArgs
// is no longer called with growing=true. The `growing` param is retained for
// call-site compatibility; if ever set, fall through to the finalized path so
// we never silently produce a wrong file.
const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
const args = [];
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
args.push(...v.args);
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
if (framerate && framerate !== 'native') args.push('-r', framerate);
args.push(...a.args);
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
if (audioChannels) args.push('-ac', String(audioChannels));
// Fragmented MOV/MP4 for direct S3 streaming (pipe:1 output — no seekable
// file on the worker disk). +frag_keyframe writes a moof/trun fragment per
// keyframe; +empty_moov puts a valid moov box at the start so the file is
// immediately parseable. Premiere Pro 25.x (2025) handles fragmented MOV
// natively. Growing-file masters use the same flags (written to SMB share).
if (fmt === 'mov' || fmt === 'mp4') {
args.push('-movflags', '+frag_keyframe+empty_moov+default_base_moof');
}
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
args.push('-f', fmt);
return args;
}
class CaptureManager {
constructor() {
this.state = {
recording: false,
sessionId: null,
processes: {},
currentSession: {},
framesReceived: 0,
currentFps: 0,
lastFrameAt: null,
lastError: null,
};
}
/**
* Build FFmpeg input arguments based on source type.
* Returns { inputArgs, isNetwork }
* @private
*/
async _buildInputArgs({ sourceType, sourceBackend = 'blackmagic', device, port, board, sourceUrl, listen, listenPort, streamKey }) {
// ── Network sources via framecache (primary when FC_SLOT_ID is set) ──────
// node-agent starts net_ingest before the sidecar, which decodes the stream
// to raw UYVY422 and registers a framecache slot. We read from that slot via
// fc_pipe — same zero-copy path as SDI sources — enabling simultaneous
// growing + proxy + HLS from any network source.
if ((sourceType === 'srt' || sourceType === 'rtmp') && process.env.FC_SLOT_ID) {
const slotId = process.env.FC_SLOT_ID;
const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe';
const WAIT_MS = 60_000; /* network sources may take longer to connect */
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const fcFps = process.env.DELTACAST_FRAMERATE || '30000/1001';
console.log(`[framecache] net slot=${slotId} size=${fcSize} fps=${fcFps}`);
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
// Pause stdout immediately so frames don't fill the OS pipe buffer (and
// block fc_pipe's write()) in the window between spawn here and the
// .pipe(ffmpeg.stdin) attach later in start(). .pipe() auto-resumes.
fcPipeProcess.stdout.pause();
fcPipeProcess.stderr.on('data', chunk => {
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
});
fcPipeProcess.on('error', err =>
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`));
return {
inputArgs: [
'-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512',
'-f', 'rawvideo',
'-pix_fmt', 'uyvy422',
'-video_size', fcSize,
'-framerate', fcFps,
'-i', 'pipe:0',
],
isNetwork: false, /* treat as raw source — no -map 0:v:0? needed */
bridgeProcess: fcPipeProcess,
audioFifo: null,
interlaced: false,
audioInputIndex: 0, /* network fc_pipe is video-only — no audio input */
_fcPipeProcess: fcPipeProcess,
};
}
// ── Legacy direct network paths (no framecache / net_ingest not running) ──
if (sourceType === 'srt') {
let url;
if (listen) {
const port = listenPort || 9000;
url = `srt://0.0.0.0:${port}?mode=listener`;
} else {
url = sourceUrl;
if (!url.includes('mode=')) {
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
}
}
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
}
if (sourceType === 'rtmp') {
if (listen) {
const port = listenPort || 1935;
const key = streamKey || 'stream';
return {
inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
isNetwork: true,
};
}
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
}
// ── Framecache path (primary for deltacast + blackmagic) ────────────────
//
// When FC_SLOT_ID is set in the sidecar env (injected by node-agent from
// the bridge's format JSON), we use the framecache shm ring buffer as the
// video source instead of named FIFOs.
//
// fc_pipe is a small C helper that opens the framecache slot as a consumer
// and writes raw UYVY422 frames to stdout. capture-manager spawns it and
// pipes its stdout to ffmpeg as a rawvideo input — same pattern as the
// existing FIFO path, but with zero-copy shm reads and independent per-
// consumer cursors. Multiple fc_pipe instances on the same slot each get
// their own cursor, enabling simultaneous growing + proxy + HLS from one
// SDI input without any frame splitting.
//
// Audio stays on the named FIFO path (same as before — audio fan-out via
// shm is a roadmap item).
//
// Falls back to the legacy FIFO path when FC_SLOT_ID is not set (e.g. on
// nodes running an older node-agent or without framecache deployed).
if ((sourceType === 'deltacast' || sourceType === 'sdi' || sourceType === 'blackmagic')
&& process.env.FC_SLOT_ID) {
const slotId = process.env.FC_SLOT_ID;
const fcPipeBin = process.env.FC_PIPE_BIN || 'fc_pipe';
const WAIT_MS = 30_000;
// Determine audio FIFO path based on source type
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
? parseInt(device, 10) : 0;
const portIdx = (sourceType === 'deltacast')
? ((typeof port === 'number' || /^\d+$/.test(String(port)))
? parseInt(port, 10) : idx)
: idx;
let audioFifoPath;
if (sourceType === 'deltacast') {
const DC_PIPE_DIR = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast';
audioFifoPath = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`;
} else {
const DL_AUDIO_DIR = process.env.DECKLINK_AUDIO_DIR || '/dev/shm/decklink';
audioFifoPath = `${DL_AUDIO_DIR}/audio-${portIdx}.fifo`;
}
// Wait up to 30s for the audio FIFO to exist (bridge starts asynchronously)
const { existsSync: _exists } = await import('node:fs');
const deadline = Date.now() + WAIT_MS;
while (Date.now() < deadline) {
if (_exists(audioFifoPath)) break;
await new Promise(r => setTimeout(r, 500));
}
if (!_exists(audioFifoPath)) {
throw new Error(
`audio FIFO not ready after ${WAIT_MS / 1000}s: ${audioFifoPath} ` +
`— is the bridge running?`
);
}
// Video dimensions and fps come from env vars injected by node-agent
// (populated from the bridge's format JSON on signal lock).
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const fcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
const fcInterlaced = process.env.DELTACAST_INTERLACED === '1';
console.log(`[framecache] slot=${slotId} size=${fcSize} fps=${fcFps} audio=${audioFifoPath}`);
// Spawn fc_pipe: opens the framecache slot with its own read cursor and
// streams raw UYVY422 frames to stdout. ffmpeg reads from the pipe as
// rawvideo input 0; audio FIFO is input 1 (same as before).
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS)], {
stdio: ['ignore', 'pipe', 'pipe'],
});
// Pause until piped to ffmpeg (avoids OS pipe-buffer fill stall — see
// the network path above for the full rationale).
fcPipeProcess.stdout.pause();
fcPipeProcess.stderr.on('data', chunk => {
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
});
fcPipeProcess.on('error', err => {
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`);
});
return {
inputArgs: [
// fc_pipe stdout → ffmpeg rawvideo input 0 (video)
// -use_wallclock_as_timestamps aligns video+audio by arrival time,
// same as the legacy FIFO path.
'-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512',
'-f', 'rawvideo',
'-pix_fmt', 'uyvy422',
'-video_size', fcSize,
'-framerate', fcFps,
'-i', 'pipe:0',
// Audio FIFO → ffmpeg input 1 (unchanged from legacy path)
'-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512',
'-f', 's16le',
'-ar', '48000',
'-ac', '2',
'-i', audioFifoPath,
],
isNetwork: false,
bridgeProcess: fcPipeProcess, /* capture-manager pipes this to ffmpeg stdin */
audioFifo: null,
interlaced: fcInterlaced,
audioInputIndex: 1, /* audio FIFO is ffmpeg input 1 */
_fcPipeProcess: fcPipeProcess, /* stored for clean stop */
};
}
// ── Legacy FIFO path for deltacast ───────────────────────────────────────
// Used when FC_SLOT_ID is not set (framecache not deployed on this node,
// or older node-agent). Will be removed once framecache is everywhere.
if (sourceType === 'deltacast') {
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
? parseInt(device, 10) : 0;
const portIdx = (typeof port === 'number' || /^\d+$/.test(String(port)))
? parseInt(port, 10) : idx;
const DC_PIPE_DIR = process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast';
const videoFifo = `${DC_PIPE_DIR}/video-${portIdx}.fifo`;
const audioFifo = `${DC_PIPE_DIR}/audio-${portIdx}.fifo`;
const { existsSync: _exists } = await import('node:fs');
const WAIT_MS = 30_000;
const POLL_MS = 500;
const deadline = Date.now() + WAIT_MS;
let videoReady = false;
let audioReady = false;
while (Date.now() < deadline) {
videoReady = _exists(videoFifo);
audioReady = _exists(audioFifo);
if (videoReady && audioReady) break;
await new Promise(r => setTimeout(r, POLL_MS));
}
if (!videoReady || !audioReady) {
throw new Error(
`deltacast bridge FIFOs not ready after ${WAIT_MS / 1000}s ` +
`(video=${videoReady} audio=${audioReady}) — is deltacast-bridge running?`
);
}
console.log(`[deltacast] port ${portIdx} FIFOs ready (legacy): ${videoFifo}, ${audioFifo}`);
const dcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const dcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
const dcInterlaced = process.env.DELTACAST_INTERLACED === '1';
return {
inputArgs: [
'-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512',
'-f', 'rawvideo',
'-pix_fmt', 'uyvy422',
'-video_size', dcSize,
'-framerate', dcFps,
'-i', videoFifo,
'-use_wallclock_as_timestamps', '1',
'-thread_queue_size', '512',
'-f', 's16le',
'-ar', '48000',
'-ac', '2',
'-i', audioFifo,
],
isNetwork: false,
bridgeProcess: null,
audioFifo: null,
interlaced: dcInterlaced,
audioInputIndex: 1, /* legacy deltacast: video FIFO=0, audio FIFO=1 */
};
}
// Default: SDI via a pluggable source backend (issue #168). The backend
// selection defaults to `blackmagic` (DeckLink) so existing SDI recorders
// behave exactly as before. Deltacast/AJA backends throw until their
// hardware/SDK plumbing lands.
const backend = sourceBackends[sourceBackend];
if (!backend) {
throw new Error(`Unknown source backend "${sourceBackend}" — expected one of: ${Object.keys(sourceBackends).join(', ')}`);
}
return await backend.buildInput({ device });
}
/**
* Build the bash orchestrator command for the GROWING master (raw2bmx).
*
* One ffmpeg reads the source once (DeckLink can't be opened twice) and writes
* THREE outputs:
* (a) MPEG-2 422 elementary VIDEO → video FIFO ─┐ raw2bmx -t op1a reads
* (b) PCM s16le AUDIO → audio FIFO ─┘ these and writes the
* growing OP1a MXF.
* (c) H.264 HLS preview (unchanged) — keeps the UI monitor live.
*
* FIFO orchestration (the tricky part — proven on the live capture node):
* raw2bmx opens its inputs lazily (video first, reads the header, THEN opens
* audio), while ffmpeg opens ALL its outputs up-front and blocks on the
* audio FIFO until a reader appears → classic open-order deadlock. We break
* it by having the parent shell PRIME both FIFOs read-write (non-blocking
* open) so neither child blocks on open. CRUCIAL: the children must NOT
* inherit a priming *writer* (it would keep the FIFO open and starve raw2bmx
* of EOF forever), so each child closes the priming FDs before exec. The
* parent holds the priming FDs (as a reader/writer) only until raw2bmx has
* opened BOTH FIFOs, then drops them — leaving ffmpeg as the SOLE writer, so
* when ffmpeg exits raw2bmx gets a clean EOF and finalizes the MXF footer.
*
* Stop/finalize: the orchestrator traps SIGINT/SIGTERM and forwards SIGINT to
* ffmpeg (clean stop → EOF to raw2bmx), then `wait`s for raw2bmx and exits
* with raw2bmx's status. The Node side spawns this with detached:true and, on
* stop(), signals it and AWAITS its exit — so the finalized, valid MXF is on
* the share before the promotion worker uploads it.
*
* Returns the argv for spawn('bash', argv).
*/
_buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, audioMap = '0:a:0?', interlaced = false }) {
const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate, interlaced ? 'i' : 'p');
const vb = videoBitrate || GROWING_DEFAULT_BITRATE;
const ach = audioChannels ? Number(audioChannels) : 2;
// ffmpeg argv (shell-quoted). One decklink read → yadif → split → 3 outputs.
const sh = (a) => "'" + String(a).replace(/'/g, `'\\''`) + "'";
// `-y`: the FIFOs are pre-created by mkfifo, so ffmpeg must overwrite them
// without the interactive "File already exists. Overwrite? [y/N]" prompt
// (which would otherwise abort the video/audio outputs and produce nothing).
const ff = ['ffmpeg', '-y', '-hide_banner', '-loglevel', 'warning', '-stats'];
// SDI input is interlaced; yadif then split into the master + preview taps.
// When there's an HLS dir we split the decode into the master ([vhi]) and
// the H.264 preview ([vlo]); with no HLS dir, split=1 (master only) so no
// split output is ever left unconnected (deltacast growing master had no
// HLS dir, leaving [vlo] orphaned -> 'split output 1 (vlo) unconnected').
const filterComplex = hlsDir
? (interlaced ? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]' : '[0:v]split=2[vhi][vlo]')
: (interlaced ? '[0:v]yadif=mode=1:deint=1,split=1[vhi]' : '[0:v]split=1[vhi]');
const ffArgs = [
...inputArgs,
'-filter_complex', filterComplex,
// (a) MPEG-2 422 elementary video → "$VF"
'-map', '[vhi]',
...GROWING_VIDEO_ELEMENTARY_ARGS,
'-b:v', vb, '-minrate', vb, '-maxrate', vb, '-bufsize', vb,
'-r', ffRate,
'-f', 'mpeg2video', '@VF@',
// (b) PCM s16le audio → "$AF"
'-map', audioMap,
'-c:a', 'pcm_s16le', '-ar', '48000', '-ac', String(ach),
'-f', 's16le', '@AF@',
];
let ffHls = [];
if (hlsDir) {
ffHls = [
// (c) H.264 HLS preview — GPU-gated, unchanged behaviour.
'-map', '[vlo]', '-map', audioMap,
...buildHlsVideoArgs(videoCodec, framerate),
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', `${hlsDir}/seg-%05d.ts`,
`${hlsDir}/index.m3u8`,
];
}
// @VF@/@AF@ are placeholders for the FIFO path shell variables; emit them as
// unquoted "$VF"/"$AF" so the shell expands them, and shell-quote everything
// else.
const placeholder = (t) => (t === '@VF@' ? '"$VF"' : t === '@AF@' ? '"$AF"' : sh(t));
const ffLine = [...ff, ...ffArgs, ...ffHls].map(placeholder).join(' ');
// raw2bmx argv. Audio is de-interleaved by raw2bmx into mono PCM tracks
// (the standard MXF mapping); --part starts a new body partition +
// IndexTableSegment every GROWING_PART_INTERVAL_FRAMES frames.
//
// CLIP TYPE: rdd9 (SMPTE RDD-9 / "Sony MXF") — NOT plain op1a and NOT
// --avid-gf. This is the make-or-break choice for Adobe Premiere:
// * --avid-gf produces an *Avid OP-Atom* growing file. That flavour needs a
// companion AAF to register the clip and is only read live by Avid Media
// Composer — Premiere cannot open it as a growing file. (Confirmed via the
// bmx mailing list + Softron/Drastic edit-while-ingest docs.) So it is
// removed.
// * Premiere's documented edit-while-ingest path expects XDCAM essence
// (MPEG-2 422 Long GOP, which we emit) wrapped as RDD-9. raw2bmx's `rdd9`
// clip type emits exactly that structure.
// --index-follows: write the IndexTableSegment in the *same* partition as the
// essence it indexes (rather than a trailing index-only partition). This is
// what lets a reader that re-scans body partitions on refresh find an index
// covering the newly-written frames — required so Premiere can seek past its
// original frame map toward the record head.
// The header Duration still starts at -1 and is only finalised in the footer
// on stop, so the inline Python dur-patch below overwrites the header Duration
// fields with the live frame count every 3s (Premiere reads the header
// Duration on each refresh; without the patch it sees duration=N/A).
const bmx = [
'raw2bmx', '-t', 'rdd9', '-o', '"$OUT"', '-f', frameRate,
'--part', String(GROWING_PART_INTERVAL_FRAMES),
'--index-follows',
rawFlag, '"$VF"',
'-s', '48000', '-q', '16', '--audio-chan', String(ach), '--pcm', '"$AF"',
];
const bmxLine = bmx
.map((t) => (t.startsWith('"$') ? t : sh(t)))
.join(' ');
// The orchestration script. `set -m` is intentionally NOT used; we manage
// children explicitly. Priming FDs 7/8; children close them before exec.
// PATCHPID: inline Python duration-patcher that runs alongside raw2bmx and
// patches the MXF header's Duration=-1 fields with the actual frame count
// every 3 seconds. Without this Premiere sees Duration=N/A even as the file
// grows, so the timeline never extends. The patcher reads the last body
// partition's IndexTableSegment (IndexStartPosition+IndexDuration) to get
// an exact frame count, then seeks back to the header Duration fields and
// overwrites them in-place. It is killed by the cleanup trap on exit.
const script = `
set -u
VF=$(mktemp -u /tmp/grow_v.XXXXXX); AF=$(mktemp -u /tmp/grow_a.XXXXXX)
OUT=${sh(outPath)}
mkfifo "$VF" "$AF"
PATCHPID=
cleanup() { rm -f "$VF" "$AF"; [ -n "$PATCHPID" ] && kill "$PATCHPID" 2>/dev/null; }
trap cleanup EXIT
# Prime both FIFOs read-write (non-blocking) to break the open-order deadlock.
exec 7<>"$VF" 8<>"$AF"
# raw2bmx: close priming FDs (no stray writer) before exec so it sees real EOF.
# CRITICAL: redirect raw2bmx stdin from /dev/null so it does NOT inherit the
# parent bash stdin. When the video source is fc_pipe (framecache), bash stdin
# carries the raw video stream destined for ffmpeg's pipe:0 — if raw2bmx also
# inherited fd 0 it would steal bytes from that stream, corrupting both the
# growing master and the ffmpeg input.
( exec 7>&- 8>&- 0</dev/null; exec ${bmxLine} ) &
BMXPID=$!
# ffmpeg: closes priming FDs and EXPLICITLY inherits bash stdin (fd 0) so that
# 'pipe:0' reads the fc_pipe video stream Node piped into this orchestrator's
# stdin. For non-fc_pipe sources (FIFO/device input) fd 0 is unused and this is
# harmless.
( exec 7>&- 8>&- 0<&0; exec ${ffLine} ) &
FFPID=$!
# Forward a clean stop to ffmpeg; raw2bmx then gets EOF and finalizes the footer.
stop() { kill -INT "$FFPID" 2>/dev/null; }
trap stop INT TERM
# Drop the parent priming FDs once raw2bmx has opened BOTH FIFOs, so ffmpeg is
# the sole writer (its EOF reaches raw2bmx). If raw2bmx dies early, bail.
for i in $(seq 1 200); do
kill -0 "$BMXPID" 2>/dev/null || break
n=$(ls -l /proc/$BMXPID/fd 2>/dev/null | grep -c -- "$VF\\|$AF")
[ "\${n:-0}" -ge 2 ] && break
sleep 0.1
done
exec 7>&- 8>&-
# No header-duration patcher is needed. In this bmx v1.6 build, raw2bmx's rdd9
# writer with --part maintains a live, correct header Duration as the file grows
# (verified on-node: ffprobe reads a growing duration mid-write, e.g. 2.04s of a
# 10s clip while still recording). A patcher (the earlier dur-patch.py) was a
# no-op here — it searched for Duration=-1, which rdd9 never writes — and opening
# the file r+b while raw2bmx appends over CIFS only adds concurrency risk.
PATCHPID=
# Wait for ffmpeg (source end), then for raw2bmx to finalize the footer.
wait "$FFPID"; FFRC=$?
wait "$BMXPID"; BMXRC=$?
echo "[grow] ffmpeg rc=$FFRC raw2bmx rc=$BMXRC out=$OUT" >&2
exit "$BMXRC"
`;
return ['-c', script];
}
/**
* Start a new capture session.
*
* Codec parameters all have sensible defaults so legacy callers (no codec
* args) still produce ProRes HQ master + H.264 proxy.
*/
async start({
assetId,
projectId,
binId,
clipName,
device,
// Deltacast: one board (index 0) with 8 channels. `port` selects the
// channel; `board` selects the physical board (default 0).
port,
board,
sourceType = 'sdi',
// Source-backend selection for SDI capture (issue #168). Defaults to
// `blackmagic` (DeckLink) so existing recorders are unaffected.
sourceBackend = 'blackmagic',
sourceUrl,
listen = false,
listenPort,
streamKey,
// ── Recording codec ─────────────────────────────────────────────
videoCodec = 'prores_hq',
videoBitrate = null,
framerate = null,
audioCodec = 'pcm_s24le',
audioBitrate = null,
audioChannels = 2,
container = 'mov',
// ── Proxy codec ─────────────────────────────────────────────────
proxyEnabled = true,
proxyVideoCodec = 'h264',
proxyVideoBitrate = '8M',
proxyFramerate = null,
proxyAudioCodec = 'aac',
proxyAudioBitrate = '192k',
proxyAudioChannels = 2,
proxyContainer = 'mp4',
}) {
this._assetIdForHls = assetId || null;
if (this.state.recording) {
throw new Error('Capture already in progress');
}
// Stop the idle confidence monitor BEFORE touching the FIFO. A second
// reader on the video FIFO halves the capture rate (~29 fps) and desyncs
// audio — so the monitor must fully release the FIFO before recording.
this.stopIdlePreview();
const sessionId = uuidv4();
const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4';
// Growing-files: write master to the SMB share instead of streaming to S3.
// Path is relative to the container's GROWING_PATH mount.
//
// Approach A: if a CIFS source is configured, mount it now. A mount failure
// is non-fatal — we fall back to S3 streaming so the recording is never
// lost.
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;
this._sessionIdForBridge = sessionId;
const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false, audioInputIndex = 0 } = await this._buildInputArgs({
sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey,
});
// ── Pre-roll: discard initial unstable frames ────────────────────────────
if (bridgeProcess && (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) {
console.log(`[capture] pre-rolling: discarding ${PRE_ROLL_SECONDS}s of frames`);
// Attach temporary drain listener.
bridgeProcess.stdout.on('data', () => {});
await new Promise(r => setTimeout(r, PRE_ROLL_SECONDS * 1000));
bridgeProcess.stdout.removeAllListeners('data');
console.log(`[capture] pre-roll complete.`);
}
const startedAt = new Date().toISOString();
const recordingStartedAt = Date.now();
// Audio input index is returned EXPLICITLY by _buildInputArgs (audioInputIndex)
// rather than guessed from sourceType/FC_SLOT_ID — that guess was wrong for
// the legacy deltacast FIFO path (which has audio at input 1 but no FC_SLOT_ID),
// silently dropping audio. Each return path now declares its own audio input:
// - deltacast/blackmagic via framecache: audio FIFO = input 1
// - legacy deltacast FIFO: audio FIFO = input 1
// - network (framecache or legacy) + DeckLink-backend SDI: audio in input 0
const audioMap = `${audioInputIndex}:a:0?`;
// Non-growing master: ffmpeg muxes the finalized MOV directly. Growing
// master: raw2bmx muxes the OP1a from elementary FIFOs (handled below via
// the orchestrator), so we don't build ffmpeg codec args here for it.
const hiresCodecArgs = growingPath ? null : buildEncodeArgs({
codec: videoCodec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels,
container,
isNetwork,
isProxy: false,
});
if (hiresCodecArgs) console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
const isInterlacedSource = sourceType === 'sdi'
|| (sourceType === 'deltacast' && interlaced)
|| ((sourceType === 'blackmagic') && interlaced);
const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : [];
// Master output destination.
//
// - 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.
//
// - Growing-files off → ffmpeg writes fragmented MOV to pipe:1 (stdout),
// which is piped directly into a multipart S3 upload. No local temp file,
// no worker disk consumed. Premiere Pro 25.x handles fragmented MOV natively.
const hiresOutput = growingPath ? growingPath : 'pipe:1';
// pipe:1 = ffmpeg stdout → S3 stream. bridgeProcess (fc_pipe) uses stdin.
const hiresStdio = bridgeProcess ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'];
// For SDI/framecache sources (including network via framecache) the live
// HLS preview is a SECOND OUTPUT of the hires ffmpeg.
const _viaFcPipeHls = !!process.env.FC_SLOT_ID;
let sdiHlsDir = null;
if ((sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic'
|| (_viaFcPipeHls && (sourceType === 'srt' || sourceType === 'rtmp')))
&& this._assetIdForHls) {
const fsMod = await import('node:fs');
sdiHlsDir = '/live/' + this._assetIdForHls;
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
}
let hiresProcess;
if (growingPath) {
// ── GROWING master: raw2bmx orchestrator ──────────────────────────
// One ffmpeg (single SDI read) → MPEG-2 422 elementary + PCM to FIFOs +
// the H.264 HLS preview; raw2bmx muxes the growing OP1a MXF from the FIFOs.
// Spawned via bash so the FIFO priming / EOF / stop-forwarding logic (see
// _buildGrowingOrchestrator) runs as one supervised unit. detached:true so
// it leads its own process group and we can clean-stop the whole pipeline.
const orchArgs = this._buildGrowingOrchestrator({
inputArgs,
videoBitrate,
// Recorder raster for the raw2bmx essence flag. recorders.js sets
// RECORDING_RESOLUTION (e.g. '1920x1080' / '1080i' / 'native'); when
// 'native'/absent, deriveGrowingRaster defaults to 1080i59.94.
resolution: process.env.RECORDING_RESOLUTION || null,
framerate,
audioChannels,
outPath: growingPath,
hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null,
videoCodec,
audioMap,
interlaced: isInterlacedSource,
});
console.log('[capture] growing master via raw2bmx; orchestrator script length=' + orchArgs[1].length);
hiresProcess = spawn('bash', orchArgs, {
stdio: bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'],
detached: true,
});
// When video comes from fc_pipe, pipe its stdout to the bash orchestrator
// stdin (which the orchestrator forwards to the ffmpeg rawvideo input).
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
bridgeProcess.stdout.pipe(hiresProcess.stdin);
bridgeProcess.on('exit', () => {
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
});
}
} else {
// ── Finalized (non-growing) master: ffmpeg muxes the MOV directly ──
let hiresArgs;
const isSdiLike = sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic';
// Network via framecache (fc_pipe) also produces its master + HLS as a
// single split ffmpeg, exactly like SDI — it reads pipe:0, not a URL.
const isNetFcPipe = !!process.env.FC_SLOT_ID && (sourceType === 'srt' || sourceType === 'rtmp');
if ((isSdiLike || isNetFcPipe) && this._assetIdForHls) {
const filterStr = isInterlacedSource
? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]'
: '[0:v]split=2[vhi][vlo]';
// Network fc_pipe is video-only (no audio input) — omit audio maps so
// ffmpeg doesn't fail trying to map a nonexistent audio stream.
const hasAudio = audioInputIndex >= 0 && !isNetFcPipe;
const masterAudioMap = hasAudio ? ['-map', audioMap] : [];
const masterAudioFilter = hasAudio
? ['-af', 'aresample=async=1:min_hard_comp=0.100000:first_pts=0'] : [];
const hlsAudioMap = hasAudio ? ['-map', audioMap] : [];
const hlsAudioCodec = hasAudio
? ['-c:a', 'aac', '-b:a', '128k', '-ar', '44100'] : [];
hiresArgs = [
...inputArgs,
'-filter_complex', filterStr,
// Output 0 — master (fragmented MOV streamed to S3 via pipe:1)
'-map', '[vhi]', ...masterAudioMap,
...masterAudioFilter,
...hiresCodecArgs,
hiresOutput,
// Output 1 — low-latency H.264 HLS preview for the UI monitor
'-map', '[vlo]', ...hlsAudioMap,
...buildHlsVideoArgs(videoCodec, framerate),
...hlsAudioCodec,
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
sdiHlsDir + '/index.m3u8',
];
console.log('[HLS] SDI/framecache preview as 2nd output -> ' + sdiHlsDir);
} else {
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
}
hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
// When video comes from fc_pipe, pipe its stdout to ffmpeg stdin.
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
bridgeProcess.stdout.pipe(hiresProcess.stdin);
bridgeProcess.on('exit', () => {
try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {}
});
}
}
// Growing: promotion worker handles S3 upload after stop.
// Non-growing: start streaming stdout directly to S3 now (multipart upload
// completes when ffmpeg exits and closes the pipe).
const processes = { hires: hiresProcess };
const uploads = {
hires: growingPath
? Promise.resolve({ growingPath })
: createUploadStream(S3_BUCKET, hiresKey, hiresProcess.stdout),
};
// ── HLS tee for legacy network sources (live preview in the UI) ──────────
// When network sources come via framecache (FC_SLOT_ID set), HLS preview is
// handled as a 2nd ffmpeg output in the hires process above (sdiHlsDir path).
// This tee is only for the legacy direct-URL network path (no framecache).
let hlsProcess = null;
let hlsDir = null;
if (isNetwork && !process.env.FC_SLOT_ID && this._assetIdForHls) {
try {
const fs = await import('node:fs');
hlsDir = '/live/' + this._assetIdForHls;
fs.mkdirSync(hlsDir, { recursive: true });
const hlsArgs = [
...inputArgs,
'-map', '0:v:0?', '-map', '0:a:0?',
...buildHlsVideoArgs(videoCodec, framerate),
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', hlsDir + '/seg-%05d.ts',
hlsDir + '/index.m3u8',
];
hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
processes.hls = hlsProcess;
console.log('[HLS] legacy-net tee started -> ' + hlsDir);
} catch (err) {
console.error('[HLS] tee failed:', err.message);
}
}
hiresProcess.stderr.on('data', (data) => {
const text = data.toString();
console.error(`[HIRES] ${text}`);
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
if (m) {
this.state.framesReceived = parseInt(m[1], 10);
this.state.lastFrameAt = new Date().toISOString();
// Use ffmpeg's own rolling fps value — it is a short-window average
// computed by ffmpeg itself and correctly reflects the true encode rate.
// The previous frame/elapsed cumulative calculation dragged low during
// startup and was permanently wrong for growing-path (bash orchestrator
// stderr doesn't emit frame= lines until ffmpeg flushes them).
const ffmpegFps = parseFloat(m[2]);
if (ffmpegFps > 0) this.state.currentFps = Math.round(ffmpegFps * 100) / 100;
}
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
this.state.lastError = text.trim().slice(0, 240);
}
});
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
// DeckLink hardware does not support two concurrent readers on the same port.
this.state.recording = true;
this.state.sessionId = sessionId;
this.state.processes = processes;
this.state.framesReceived = 0;
this.state.currentFps = 0;
this.state.lastFrameAt = null;
this.state.lastError = null;
this.state.recordingStartedAt = Date.now();
this.state.currentSession = {
sessionId,
projectId,
binId,
clipName,
device,
sourceType,
sourceUrl,
assetId,
hiresKey,
proxyKey,
growingPath,
audioFifo,
startedAt,
duration: 0,
_fcPipeProcess: bridgeProcess || null, /* fc_pipe process, if framecache path used */
uploads,
codecs: {
videoCodec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels, container,
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
},
};
// Fire-and-forget: grab the first frame for the live poster thumbnail.
// Only for sources that produce an HLS dir (sdi/deltacast); never blocks start().
if (sdiHlsDir && assetId) {
this._publishLiveThumbnail({ assetId, hlsDir: sdiHlsDir }).catch(() => {});
}
return this._formatSessionResponse();
}
// ── Idle confidence monitor ────────────────────────────────────────────
// A low-rate (1 fps) single-JPEG confidence snapshot for the recorder tile
// when the recorder is NOT actively recording.
//
// CRITICAL: this must NEVER read the video FIFO while a recording is active.
// A second continuous reader on the same /dev/shm/deltacast/video-N.fifo
// splits the frames between the two readers, halving the capture rate to
// ~29 fps (the root cause of the out-of-sync / fast-playback bug). So the
// monitor:
// 1. runs ONLY when this.state.recording === false
// 2. opens the FIFO, grabs ONE frame, scales to a small JPEG, exits
// 3. sleeps 1s, repeats — yielding the FIFO completely between grabs
// 4. is fully stopped the instant a recording starts (see start())
async startIdlePreview() {
if (this._previewTimer || this._previewProc) return; // already running
if (this.state.recording) return; // never run during an active recording
const sourceType = process.env.SOURCE_TYPE;
const recorderId = process.env.RECORDER_ID;
if (!recorderId || !['deltacast', 'sdi'].includes(sourceType)) return;
if (sourceType !== 'deltacast') return; // SDI/blackmagic snapshot TBD
const previewDir = `/live/preview-${recorderId}`;
try { await fs.promises.mkdir(previewDir, { recursive: true }); } catch (_) {}
const size = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
let cfg = {};
try { cfg = JSON.parse(process.env.SOURCE_CONFIG || '{}'); } catch (_) {}
const port = cfg.port ?? 0;
const videoFifo = (process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast') + `/video-${port}.fifo`;
const outJpg = previewDir + '/frame.jpg';
const tmpJpg = previewDir + '/frame.tmp.jpg';
this._previewStop = false;
console.log('[preview] starting 1fps confidence monitor for', recorderId);
const grabOnce = () => new Promise((resolve) => {
// Never compete with an active recording.
if (this._previewStop || this.state.recording) return resolve();
// -frames:v 1 reads exactly ONE frame then exits, releasing the FIFO.
// Read-rate is capped by -readrate 1 so the single-frame read consumes
// ~1 frame worth of FIFO data, not a burst.
const ff = spawn('ffmpeg', [
'-y',
'-f', 'rawvideo', '-pix_fmt', 'uyvy422', '-s', size,
'-i', videoFifo,
'-frames:v', '1',
'-vf', 'scale=480:-2',
'-q:v', '5',
tmpJpg,
], { stdio: ['ignore', 'ignore', 'ignore'] });
this._previewProc = ff;
const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 4000);
ff.on('exit', () => {
clearTimeout(killTimer);
this._previewProc = null;
// Atomic-ish swap so the served frame is never half-written.
fs.rename(tmpJpg, outJpg, () => resolve());
});
ff.on('error', () => { clearTimeout(killTimer); this._previewProc = null; resolve(); });
});
const loop = async () => {
while (!this._previewStop) {
await grabOnce();
if (this._previewStop) break;
await new Promise(r => { this._previewTimer = setTimeout(r, 1000); });
}
};
loop();
}
stopIdlePreview() {
this._previewStop = true;
if (this._previewTimer) { clearTimeout(this._previewTimer); this._previewTimer = null; }
if (this._previewProc) {
try { this._previewProc.kill('SIGKILL'); } catch (_) {}
this._previewProc = null;
}
}
async stop(sessionId) {
if (!this.state.recording || this.state.sessionId !== sessionId) {
throw new Error('No active capture session or session ID mismatch');
}
this.stopIdlePreview();
const { processes, currentSession } = this.state;
const isGrowing = !!currentSession.growingPath;
// Send SIGINT and WAIT for the master writer to exit cleanly.
// - Non-growing: SIGINT flushes ffmpeg's MOV trailer (the moov atom with
// full sample tables). Uploading before finalize → "moov atom not found".
// - Growing: `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 (_) {} }
// fc_pipe process (framecache consumer) — stop after ffmpeg so it sees EOF
// naturally via EPIPE when ffmpeg stdin closes. SIGTERM as belt-and-suspenders.
if (currentSession._fcPipeProcess) {
try { currentSession._fcPipeProcess.kill('SIGTERM'); } catch (_) {}
}
/* processes.bridge: removed — bridge is managed by node-agent, not per-session */
// Wait for the master writer to finalize before we read/upload the file.
await waitExit(processes.hires);
// Release the CIFS mount (best-effort) once the ffmpeg writers are done with
// it. The promotion worker reads the staged file from the host/S3 side, not
// through this container's mount, so unmounting here is safe.
unmountGrowingShare();
try {
// Non-growing: S3 upload was streaming from ffmpeg stdout — it completes
// when ffmpeg exits and closes the pipe (waitExit above ensures that).
// Growing: promotion worker handles S3.
const uploadPromises = [];
if (currentSession.uploads.hires) uploadPromises.push(currentSession.uploads.hires);
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
await Promise.all(uploadPromises);
} catch (error) {
console.error('Error during upload completion:', error);
}
if (currentSession.audioFifo) {
try { unlinkSync(currentSession.audioFifo); } catch (_) {}
}
const stoppedAt = new Date().toISOString();
const startTime = new Date(currentSession.startedAt);
const stopTime = new Date(stoppedAt);
const duration = Math.round((stopTime - startTime) / 1000);
this.state.recording = false;
this.state.sessionId = null;
this.state.processes = {};
this.state.recordingStartedAt = null;
// No frames received → the upload (if any) produced a 0-byte object.
// Surface that so the shutdown handler can mark the asset as 'error'
// instead of posting a broken hi-res key downstream.
const framesReceived = this.state.framesReceived;
return {
sessionId,
assetId: currentSession.assetId,
projectId: currentSession.projectId,
binId: currentSession.binId,
clipName: currentSession.clipName,
sourceType: currentSession.sourceType,
hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey,
growingPath: currentSession.growingPath || null,
startedAt: currentSession.startedAt,
stoppedAt,
duration,
framesReceived,
empty: framesReceived === 0,
};
}
// Grab the first video frame from the live HLS output and publish it as the
// asset's poster thumbnail, so the library shows a real frame instead of the
// "connecting…" placeholder while recording is still in progress.
//
// Runs entirely on the sidecar (where the HLS segments physically exist):
// 1. poll /live/<assetId> for the first seg-*.ts (bridge/ffmpeg warm-up)
// 2. ffmpeg -i <segment> -frames:v 1 -> scaled JPEG
// 3. upload JPEG to S3 at thumbnails/<assetId>.jpg (matches mam-api convention)
// 4. POST /assets/<assetId>/live-thumbnail so the row gets thumbnail_s3_key
//
// Best-effort and non-blocking: any failure is logged and swallowed — the
// post-stop thumbnail job still produces the final thumbnail regardless.
async _publishLiveThumbnail({ assetId, hlsDir }) {
if (!assetId || !hlsDir) return;
const mamUrl = process.env.MAM_API_URL || 'http://mam-api:3000';
const tmpJpg = `/tmp/livethumb-${assetId}.jpg`;
const thumbKey = `thumbnails/${assetId}.jpg`;
try {
// 1. Wait up to 30s for the first HLS segment to appear.
const deadline = Date.now() + 30_000;
let segment = null;
while (Date.now() < deadline && this.state.recording && this.state.currentSession.assetId === assetId) {
try {
const entries = await fs.promises.readdir(hlsDir);
const segs = entries.filter(f => /^seg-\d+\.ts$/.test(f)).sort();
if (segs.length > 0) { segment = `${hlsDir}/${segs[0]}`; break; }
} catch (_) { /* dir not created yet */ }
await new Promise(r => setTimeout(r, 500));
}
if (!segment) { console.warn(`[livethumb] no segment for ${assetId} within 30s`); return; }
// 2. Extract the first frame, scaled to 640px wide (yuvj420p for broad JPEG
// decoder compatibility), as a single still.
await new Promise((resolve, reject) => {
const ff = spawn('ffmpeg', [
'-y', '-i', segment,
'-frames:v', '1',
'-vf', 'scale=640:-2',
'-pix_fmt', 'yuvj420p',
tmpJpg,
], { stdio: ['ignore', 'ignore', 'pipe'] });
let err = '';
ff.stderr.on('data', d => { err += d.toString(); });
ff.on('error', reject);
ff.on('exit', code => code === 0 ? resolve() : reject(new Error(`ffmpeg exit ${code}: ${err.slice(-200)}`)));
});
// 3. Upload to S3.
const size = statSync(tmpJpg).size;
if (size <= 0) throw new Error('extracted thumbnail is 0 bytes');
await createUploadStream(S3_BUCKET, thumbKey, createReadStream(tmpJpg));
// 4. Tell mam-api the key (only sticks while the asset is still 'live').
const resp = await fetch(`${mamUrl}/api/v1/assets/${assetId}/live-thumbnail`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
...(process.env.MAM_API_TOKEN ? { Authorization: `Bearer ${process.env.MAM_API_TOKEN}` } : {}),
},
body: JSON.stringify({ thumbnailKey: thumbKey }),
});
if (!resp.ok) throw new Error(`mam-api ${resp.status}: ${(await resp.text()).slice(0, 200)}`);
console.log(`[livethumb] published poster for ${assetId} (${thumbKey})`);
} catch (err) {
console.warn(`[livethumb] failed for ${assetId}:`, err.message);
} finally {
try { unlinkSync(tmpJpg); } catch (_) {}
}
}
getStatus() {
if (!this.state.recording) return { recording: false };
const startTime = new Date(this.state.currentSession.startedAt);
const now = new Date();
const duration = Math.round((now - startTime) / 1000);
const lastFrameAt = this.state.lastFrameAt;
const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
let signal = 'connecting';
if (this.state.framesReceived > 0) {
signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
} else if (this.state.lastError) {
signal = 'error';
}
return {
recording: true,
sessionId: this.state.sessionId,
sourceType: this.state.currentSession.sourceType,
device: this.state.currentSession.device,
clipName: this.state.currentSession.clipName,
projectId: this.state.currentSession.projectId,
binId: this.state.currentSession.binId,
duration,
startedAt: this.state.currentSession.startedAt,
signal,
framesReceived: this.state.framesReceived,
currentFps: this.state.currentFps,
lastFrameAt,
msSinceFrame,
lastError: this.state.lastError,
codecs: this.state.currentSession.codecs,
};
}
_formatSessionResponse() {
const { currentSession, sessionId } = this.state;
return {
sessionId,
projectId: currentSession.projectId,
binId: currentSession.binId,
clipName: currentSession.clipName,
device: currentSession.device,
sourceType: currentSession.sourceType,
hiresKey: currentSession.hiresKey,
proxyKey: currentSession.proxyKey,
startedAt: currentSession.startedAt,
codecs: currentSession.codecs,
};
}
}
export default new CaptureManager();
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT, sourceBackends };