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

1415 lines
67 KiB
JavaScript
Raw Normal View History

import { spawn, execFileSync } from 'child_process';
import { mkdirSync, writeFileSync } from 'node:fs';
import fs from 'node:fs';
import { dirname } from 'node:path';
import { v4 as uuidv4 } from 'uuid';
import { createUploadStream } from './s3/client.js';
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
// In standby mode the framecache slot has been warm for a long time — reduce
// pre-roll to 1s (just enough for fc_pipe to sync its read cursor).
// Override with PRE_ROLL_SECONDS env var if needed.
const _standbyMode = process.env.STANDBY === '1';
const PRE_ROLL_SECONDS = parseInt(process.env.PRE_ROLL_SECONDS || (_standbyMode ? '1' : '5'), 10);
// Growing-files mode: writes the master to a local SMB-backed share that the
// editor can mount, instead of streaming to S3 in real time. The promotion
// worker uploads the finalized file to S3 after the recording stops.
// Toggled per-recorder via `GROWING_ENABLED=true`, delivered per-session on
// /capture/start (read fresh from process.env at record time in start(), NOT
// cached here — standby sidecars boot with it false).
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
// (the host-bound /growing volume is used instead, or S3 streaming if growing
// is off).
// mount.cifs needs a UNC source (//host/share). Operators (and Settings) often
// store the share as an `smb://host/share` URL or a Windows `\\host\share`
// path; the kernel rejects those outright ("Mounting cifs URL not implemented
// yet"), which silently drops us back to S3. Normalize any of these forms to
// the `//host/share` UNC the mount helper accepts.
function toUncShare(raw) {
if (!raw) return '';
let s = String(raw).trim().replace(/\\/g, '/'); // \\host\share -> //host/share
s = s.replace(/^smb:\/\//i, '//'); // smb://host/share -> //host/share
if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share
return s;
}
// Growing SMB params are read FRESH from process.env at mount time, NOT cached
// at module load. Standby capture containers boot with these unset and receive
// them per-session over /capture/start (capture.js sets process.env before
// captureManager.start()). Caching them in module-level consts at import time
// captured the empty boot values, so the mount silently no-op'd and growing
// fell back to S3 — producing .mov instead of the VC-3/DNxHD .mxf.
const growingSmbConfig = () => ({
mount: toUncShare(process.env.GROWING_SMB_MOUNT || ''),
username: process.env.GROWING_SMB_USERNAME || '',
password: process.env.GROWING_SMB_PASSWORD || '',
vers: process.env.GROWING_SMB_VERS || '3.0',
});
const SMB_CREDS_FILE = '/run/smb-creds';
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
// mounted, or a host bind-mount is present).
function isMounted(path) {
try { execFileSync('mountpoint', ['-q', path]); return true; }
catch { return false; }
}
// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only
// file (NOT the command line) so they never appear in `ps`/process listings.
// Returns true on success (or if already mounted), false on failure — callers
// fall back to S3 streaming so a recording is never lost.
function mountGrowingShare() {
const cfg = growingSmbConfig();
if (!cfg.mount) {
console.warn('[capture] growing requested but GROWING_SMB_MOUNT is empty — falling back to S3');
return false;
}
try {
if (isMounted(GROWING_PATH)) {
console.log('[capture] growing share already mounted at', GROWING_PATH);
return true;
}
try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
// Pass credentials inline rather than via a credentials= file. Some SMB
// servers (notably TrueNAS SMB3) reject the credentials-file form with
// EACCES (-13) — "cannot mount ... read-only" — even though the very same
// username/password mount inline and smbclient lists the share fine. Inline
// user=/password= is the reliable form here.
const opts = [
`username=${cfg.username}`,
`password=${cfg.password}`,
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
`vers=${cfg.vers}`,
].join(',');
execFileSync('mount', ['-t', 'cifs', cfg.mount, GROWING_PATH, '-o', opts],
{ stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] mounted CIFS growing share', cfg.mount, '->', GROWING_PATH);
return true;
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
console.error('[capture] CIFS mount failed (falling back to S3 streaming):', stderr);
return false;
}
}
// Best-effort unmount on session stop. Ignores "not mounted".
function unmountGrowingShare() {
try {
if (isMounted(GROWING_PATH)) {
execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
console.log('[capture] unmounted growing share at', GROWING_PATH);
}
} catch (err) {
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
console.warn('[capture] growing share unmount failed (ignored):', stderr);
}
}
// ── Codec catalogue ──────────────────────────────────────────────────────
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
// / pix_fmt are layered on top from the per-recorder configuration.
const VIDEO_CODECS = {
prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true, pixFmt: 'yuv422p' },
dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
// All-Intra HEVC on NVENC — the growing-file master codec.
// Goal: every frame an IDR (all-intra), so a still-growing file is decodable
// to its last complete frame — the prerequisite for edit-while-record.
//
// NVENC will NOT accept `-g 1`: InitializeEncoder enforces
// "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1
// is rejected with EINVAL (validated on the L4, driver 595). The working
// recipe for true all-intra is therefore:
// -bf 0 no B-frames
// -g 600 large GOP just to satisfy the init check
// -forced-idr 1 forced keyframes are emitted as IDR
// -force_key_frames expr:1 force a keyframe on EVERY frame
// → ffprobe confirms pict_type = I for all frames.
//
// Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof),
// NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted").
// The frag-MOV index is not deferred to EOF, so the file stays readable while
// growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.)
//
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
// GROWING-file variant: every frame an IDR (all-intra) so a still-growing
// file is decodable to its last complete frame. This is HEAVY — only used when
// growing-files is on (see hevcNvencArgs()).
hevc_nvenc: {
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
bitrateControl: true,
pixFmt: 'p010le',
},
};
// HEVC/NVENC encode args, GOP structure chosen by mode.
// growing=false (normal record): efficient long-GOP (2s @ fps) HEVC. NVENC
// easily sustains 1080p59.94 10-bit here, so no frame drops → audio/video
// lengths stay locked. This is the DEFAULT for recorders.
// growing=true (edit-while-record): ALL-INTRA (every frame an IDR) so the
// growing file is decodable to its last written frame — the requirement for
// Premiere's growing-file refresh. Much heavier, only used when needed.
// `force_key_frames expr:1` (all-intra) is the ~4× compute path that was
// crippling realtime when applied to every recording; gating it on `growing`
// is the fix for the dropped-frame A/V drift.
// Parse a framerate that may be a rational like "60000/1001" (=59.94) OR a plain
// "59.94"/"60". Number.parseFloat("60000/1001") returns 60000 (stops at '/'),
// which made the GOP 120000 instead of ~120 — effectively open-GOP. Handle the
// rational form explicitly.
function parseFps(framerate, fallback = 60) {
if (framerate == null) return fallback;
const s = String(framerate).trim();
if (s.includes('/')) {
const [n, d] = s.split('/').map(Number);
if (Number.isFinite(n) && Number.isFinite(d) && d !== 0) return n / d;
}
const f = Number.parseFloat(s);
return Number.isFinite(f) && f > 0 ? f : fallback;
}
// Which physical GPU this sidecar's NVENC encodes should use. node-agent
// round-robins capture ports across the host's GPUs and passes the index here.
// We MUST select it explicitly with ffmpeg's `-gpu N` because the capture
// sidecars run Privileged (so they see every /dev/nvidiaN regardless of
// NVIDIA_VISIBLE_DEVICES) — without -gpu, nvenc defaults every session to GPU 0
// and all 8 ports pile onto one card → it falls below realtime → video freezes.
const CAPTURE_GPU_INDEX = (() => {
const v = process.env.CAPTURE_GPU_INDEX;
if (v == null || v === '' || v === 'all') return null;
const n = parseInt(v, 10);
return Number.isInteger(n) && n >= 0 ? n : null;
})();
// `-gpu N` must come BEFORE the input/encoder is initialized; ffmpeg accepts it
// as an encoder option right after -c:v. Returns [] when no pin is configured.
const nvencGpuSel = () => (CAPTURE_GPU_INDEX != null ? ['-gpu', String(CAPTURE_GPU_INDEX)] : []);
// Optional fixed A/V alignment trim for the SDI/Deltacast audio input. The
// deltacast bridge captures audio and video on separate VHD streams; any
// constant capture-path latency difference between them shows as a fixed A/V
// offset (e.g. audio slightly ahead of video) even though stream LENGTHS stay
// locked (no drift). AUDIO_OFFSET_MS lets an operator dial that out without a
// rebuild: POSITIVE value DELAYS audio (use when audio is AHEAD of video),
// NEGATIVE advances it. Applied as ffmpeg `-itsoffset` on the audio input only.
// Default 0 = no change (fully non-destructive). Range-clamped to ±1000 ms.
const audioOffsetArgs = () => {
const raw = parseFloat(process.env.AUDIO_OFFSET_MS || '0');
if (!Number.isFinite(raw) || raw === 0) return [];
const ms = Math.max(-1000, Math.min(1000, raw));
return ['-itsoffset', (ms / 1000).toFixed(4)];
};
function hevcNvencArgs(framerate, growing) {
const base = ['-c:v', 'hevc_nvenc', ...nvencGpuSel(), '-preset', 'p4', '-rc', 'vbr', '-profile:v', 'main10'];
if (growing) {
return [...base, '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1'];
}
// Normal long-GOP: ~2s keyframe interval, 2 B-frames. Realtime-friendly.
const fps = parseFps(framerate, 60);
const gop = Math.max(2, Math.round(fps * 2));
return [...base, '-bf', '2', '-g', String(gop)];
}
// nvenc codecs available in the capture image. Used both to validate the master
// codec and (issue #164) as the GPU-availability signal for the HLS preview.
const NVENC_CODECS = new Set(['h264_nvenc', 'hevc_nvenc']);
// ── GPU availability for this sidecar (issue #164) ───────────────────────
// The HLS monitor preview should be GPU-encoded (h264_nvenc) when — and only
// when — the GPU is actually attached to this capture container. A non-GPU
// recorder must keep using libx264, otherwise ffmpeg would fail to open the
// nvenc encoder and break the preview.
//
// Two signals, OR'd for robustness:
// 1) The master video codec is an nvenc codec. recorders.js derives `useGpu`
// from exactly this (GPU_CODECS = [hevc_nvenc, h264_nvenc]) and node-agent
// only attaches the NVIDIA runtime when useGpu is set — so an nvenc master
// codec is a reliable proxy for "this sidecar has the GPU".
// 2) node-agent injects NVIDIA_VISIBLE_DEVICES into the sidecar env whenever
// useGpu is set. This is the most direct in-process evidence the runtime
// attached a GPU, and covers the (currently unused) case where the GPU is
// present but the master codec is a CPU codec.
function gpuAvailableForPreview(masterCodec) {
if (NVENC_CODECS.has(masterCodec)) return true;
const vis = process.env.NVIDIA_VISIBLE_DEVICES;
if (vis && vis !== 'void' && vis !== 'none') return true;
return false;
}
// Build the HLS preview video-encode args. `segTime` is the HLS segment length
// (seconds); we pin the GOP/keyframe interval to one IDR per segment so every
// segment starts on a keyframe (misaligned keyframes were the root cause of the
// playout preview black/flashing bug — keep the preview robust).
function buildHlsVideoArgs(masterCodec, framerate) {
// Frames-per-segment for keyframe alignment. The SDI preview runs at the
// capture framerate; default to 30 (matches the test-card rate) when unknown.
const fps = parseFps(framerate, 30);
const segTime = 2; // matches -hls_time below
const gop = Math.max(1, Math.round(fps * segTime));
if (gpuAvailableForPreview(masterCodec)) {
// Low-latency NVENC preset (p1 + ll tune). forced-idr + a keyframe every GOP
// frames keeps segment boundaries on IDR frames so hls.js can sync cleanly.
return [
'-c:v', 'h264_nvenc', ...nvencGpuSel(), '-preset', 'p1', '-tune', 'll',
'-pix_fmt', 'yuv420p', '-b:v', '2M',
'-g', String(gop), '-forced-idr', '1', '-sc_threshold', '0',
];
}
// No GPU → keep the original CPU encode (must not break a non-GPU recorder).
return [
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
'-pix_fmt', 'yuv420p', '-b:v', '2M',
'-g', String(gop), '-sc_threshold', '0',
];
}
const AUDIO_CODECS = {
pcm_s16le: { args: ['-c:a', 'pcm_s16le'], bitrateControl: false },
pcm_s24le: { args: ['-c:a', 'pcm_s24le'], bitrateControl: false },
pcm_s32le: { args: ['-c:a', 'pcm_s32le'], bitrateControl: false },
aac: { args: ['-c:a', 'aac'], bitrateControl: true },
ac3: { args: ['-c:a', 'ac3'], bitrateControl: true },
opus: { args: ['-c:a', 'libopus'], bitrateControl: true },
flac: { args: ['-c:a', 'flac'], bitrateControl: false },
};
const CONTAINER_FMT = {
mov: 'mov',
mp4: 'mp4',
mkv: 'matroska',
mxf: 'mxf',
ts: 'mpegts',
};
const CONTAINER_EXT = {
mov: 'mov', mp4: 'mp4', mkv: 'mkv', mxf: 'mxf', ts: 'ts',
};
// Growing-file (edit-while-record) master format — VC-3 / DNxHD in MXF OP1a,
// written DIRECTLY by ffmpeg's native MXF muxer (NO raw2bmx, NO FIFO, NO
// elementary-essence orchestration). ffmpeg writes a frame-wrapped OP1a whose
// BODY grows readably while still being written: the partial file opens as
// 'mxf' and decodes mid-write, and finalizes with a valid Duration + footer on
// a clean SIGINT. This is the same growing VC-3 workflow vMix uses and that
// Adobe Premiere imports live (with "Automatically refresh growing files"
// enabled). The single-input fc_pipe AVI feeds it (video + frame-coupled
// embedded audio in one stream); see _buildGrowingVc3Mxf() and start().
//
// 'vc3_90' -> VC-3 90 Mbps, lighter storage. DEFAULT.
// 'vc3_220' -> VC-3/DNxHD 220 Mbps (VC3_1080p_1238), highest quality.
//
// growingCodec() reads GROWING_CODEC fresh from env at record time (standby
// sidecars boot unset and receive it per-session via /capture/start).
const GROWING_EXT = 'mxf';
const growingCodec = () => {
const v = process.env.GROWING_CODEC;
if (v === 'vc3_220') return 'vc3_220';
return 'vc3_90'; // default
};
// Bitrate for the dnxhd encoder, per growing codec value.
const growingVc3Bitrate = (codec) => (codec === 'vc3_220' ? '220M' : '90M');
// File extension per growing codec — always MXF for VC-3.
const growingExtFor = (_codec) => 'mxf';
// ── Source-backend abstraction (issue #168) ──────────────────────────────
// The capture input was historically hard-wired to a single `-f decklink -i …`
// construction. To allow other SDI capture cards (Deltacast, AJA) to be added
// later without touching the encode/output/HLS pipeline, the per-backend FFmpeg
// INPUT-arg construction now lives behind this map. Each backend exposes:
//
// buildInput(ctx) -> { inputArgs, isNetwork } (may be async)
//
// where `ctx` carries the resolved recorder fields the backend needs (device).
// The rest of capture-manager consumes the returned `inputArgs` unchanged, so
// adding a backend is purely additive.
//
// IMPORTANT: `blackmagic` is a behaviour-preserving extraction of the previous
// default DeckLink path — for an existing DeckLink recorder the produced ffmpeg
// input args are byte-for-byte identical to the pre-refactor code. The
// `deltacast`/`aja` entries are stubs that throw until the hardware/SDK plumbing
// lands.
const sourceBackends = {
// BlackMagic DeckLink over SDI (the only backend implemented today).
// device may be an integer index (0-based) or a full device name string.
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
// Map integer index -> name using ffmpeg -sources decklink at runtime.
//
// ffmpeg -sources decklink output format:
// Auto-detected sources for decklink:
// DeckLink Duo 2
// DeckLink Duo 2 (2)
// Lines containing device names start with whitespace; the header line
// starts with a non-space character. Previous code used a v4l2-style
// hex-address regex that never matched DeckLink output → index 1+ always
// fell through to a wrong fallback, producing black output from port 2+.
blackmagic: {
async buildInput({ device }) {
let deckLinkName = String(device);
if (typeof device === 'number' || /^\d+$/.test(String(device))) {
const idx = parseInt(device, 10);
try {
const { execSync } = await import('child_process');
const out = execSync('ffmpeg -hide_banner -sources decklink 2>&1', { encoding: 'utf-8', timeout: 5000 });
const names = [];
for (const line of out.split('\n')) {
// DeckLink source lines: " 81:76669a80:00000000 [DeckLink Duo (1)] (none)"
const m = line.match(/^\s+[0-9a-f:]+\s+\[([^\]]+)\]/);
if (m) names.push(m[1]);
}
if (names[idx]) {
deckLinkName = names[idx];
console.log(`[capture] DeckLink index ${idx} → "${deckLinkName}" (from ${names.length} detected: ${names.join(', ')})`);
} else {
// Fallback: cannot determine model name without enumeration.
// Log a warning — operator should check the detected device list.
console.warn(`[capture] DeckLink index ${idx} out of range (detected ${names.length} devices: ${names.join(', ')}). Falling back to index-only input — capture may fail.`);
deckLinkName = `DeckLink (${idx})`;
}
} catch (err) {
console.warn(`[capture] ffmpeg -sources decklink failed: ${err.message}. Using index ${device} directly.`);
// Pass the numeric index directly; some ffmpeg builds accept it.
deckLinkName = String(device);
}
}
return {
inputArgs: ['-f', 'decklink', '-i', deckLinkName],
isNetwork: false,
};
},
},
deltacast: {
// Unused stub — deltacast capture uses sourceType='deltacast' path in
// _buildInputArgs, not the sourceBackends map.
buildInput() {
throw new Error('deltacast: use sourceType="deltacast" not sourceBackend');
},
},
aja: {
buildInput() {
throw new Error('aja backend not yet implemented — requires hardware');
},
},
};
function buildEncodeArgs({
codec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels,
container, isNetwork, isProxy = false,
growing = false,
}) {
// NOTE: the growing master is NOT muxed here — the growing VC-3/DNxHD MXF is
// built by _buildGrowingVc3Mxf() and spawned directly in start(). So
// buildEncodeArgs is never called with growing=true. The `growing` param is
// retained for call-site compatibility; if ever set, fall through to the
// finalized path so we never silently produce a wrong file.
const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
const args = [];
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
// hevc_nvenc GOP structure is mode-dependent: all-intra only for growing
// files, efficient long-GOP for normal record (so NVENC stays realtime and
// doesn't drop frames). All other codecs use their static arg set.
if (codec === 'hevc_nvenc') {
args.push(...hevcNvencArgs(framerate, growing));
} else {
args.push(...v.args);
}
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
if (framerate && framerate !== 'native') args.push('-r', framerate);
args.push(...a.args);
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
if (audioChannels) args.push('-ac', String(audioChannels));
// Fragmented MOV/MP4 for direct S3 streaming (pipe:1 output — no seekable
// file on the worker disk). +frag_keyframe writes a moof/trun fragment per
// keyframe; +empty_moov puts a valid moov box at the start so the file is
// immediately parseable. Premiere Pro 25.x (2025) handles fragmented MOV
// natively. Growing-file masters use the same flags (written to SMB share).
if (fmt === 'mov' || fmt === 'mp4') {
args.push('-movflags', '+frag_keyframe+empty_moov+default_base_moof');
}
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
args.push('-f', fmt);
return args;
}
class CaptureManager {
constructor() {
this.state = {
recording: false,
sessionId: null,
processes: {},
currentSession: {},
framesReceived: 0,
currentFps: 0,
lastFrameAt: null,
lastError: null,
};
}
/**
* Build FFmpeg input arguments based on source type.
* Returns { inputArgs, isNetwork }
* @private
*/
async _buildInputArgs({ sourceType, sourceBackend = 'blackmagic', device, port, board, sourceUrl, listen, listenPort, streamKey }) {
feat(framecache): phase 5 — network ingest (RTMP/SRT) via framecache - services/framecache/src/net_ingest.c: new network ingest process - Spawns ffmpeg to decode SRT/RTMP → raw UYVY422 stdout - Reads decoded frames and writes into framecache slot via shm - Registers slot with framecache HTTP API on startup - Deregisters slot on clean exit (SIGTERM) - Reconnect loop for listener mode (stays alive between sessions) - --url, --slot-id, --fc-url, --width, --height, --fps-num/den, --source-type, --listen, --listen-port, --stream-key args - Emits format JSON to stderr on first frame - services/framecache/CMakeLists.txt: add net_ingest target - services/framecache/Dockerfile: copy net_ingest to runtime image - services/node-agent/index.js: - startNetIngest() / stopNetIngest(): lifecycle management per recorder - Spawns net_ingest before sidecar start for srt/rtmp sourceTypes - Injects FC_SLOT_ID=net-<containerId> into sidecar env - Sets IpcMode=host for network sidecars using framecache - Maps temp id → real containerId after container create - stopNetIngest() called on sidecar stop - NET_INGEST_BIN env var (default: docker exec framecache net_ingest) - services/capture/src/capture-manager.js: - _buildInputArgs srt/rtmp: framecache path when FC_SLOT_ID set (spawns fc_pipe, uses pipe:0 rawvideo input — same as SDI path) - Falls back to direct URL when FC_SLOT_ID not set (legacy path) - audioMap: network via framecache uses '0:a:0?' (video-only fc_pipe, no audio FIFO — audio-in-shm is roadmap) - HLS tee: sdiHlsDir covers network-via-framecache; legacy tee gated on !FC_SLOT_ID to avoid duplicate HLS outputs - fc_pipe piped to ffmpeg stdin for network framecache path - docker-compose.worker.yml: FC_URL + NET_INGEST_BIN in node-agent env
2026-06-03 11:37:17 -04:00
// ── 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();
feat(framecache): phase 5 — network ingest (RTMP/SRT) via framecache - services/framecache/src/net_ingest.c: new network ingest process - Spawns ffmpeg to decode SRT/RTMP → raw UYVY422 stdout - Reads decoded frames and writes into framecache slot via shm - Registers slot with framecache HTTP API on startup - Deregisters slot on clean exit (SIGTERM) - Reconnect loop for listener mode (stays alive between sessions) - --url, --slot-id, --fc-url, --width, --height, --fps-num/den, --source-type, --listen, --listen-port, --stream-key args - Emits format JSON to stderr on first frame - services/framecache/CMakeLists.txt: add net_ingest target - services/framecache/Dockerfile: copy net_ingest to runtime image - services/node-agent/index.js: - startNetIngest() / stopNetIngest(): lifecycle management per recorder - Spawns net_ingest before sidecar start for srt/rtmp sourceTypes - Injects FC_SLOT_ID=net-<containerId> into sidecar env - Sets IpcMode=host for network sidecars using framecache - Maps temp id → real containerId after container create - stopNetIngest() called on sidecar stop - NET_INGEST_BIN env var (default: docker exec framecache net_ingest) - services/capture/src/capture-manager.js: - _buildInputArgs srt/rtmp: framecache path when FC_SLOT_ID set (spawns fc_pipe, uses pipe:0 rawvideo input — same as SDI path) - Falls back to direct URL when FC_SLOT_ID not set (legacy path) - audioMap: network via framecache uses '0:a:0?' (video-only fc_pipe, no audio FIFO — audio-in-shm is roadmap) - HLS tee: sdiHlsDir covers network-via-framecache; legacy tee gated on !FC_SLOT_ID to avoid duplicate HLS outputs - fc_pipe piped to ffmpeg stdin for network framecache path - docker-compose.worker.yml: FC_URL + NET_INGEST_BIN in node-agent env
2026-06-03 11:37:17 -04:00
fcPipeProcess.stderr.on('data', chunk => {
process.stderr.write(`[fc_pipe:${slotId}] ${chunk}`);
});
fcPipeProcess.on('error', err =>
console.error(`[fc_pipe:${slotId}] spawn error: ${err.message}`));
return {
inputArgs: [
// No -use_wallclock_as_timestamps — framecache delivers CFR frames
// at the original ingest rate; -framerate produces correct timestamps.
feat(framecache): phase 5 — network ingest (RTMP/SRT) via framecache - services/framecache/src/net_ingest.c: new network ingest process - Spawns ffmpeg to decode SRT/RTMP → raw UYVY422 stdout - Reads decoded frames and writes into framecache slot via shm - Registers slot with framecache HTTP API on startup - Deregisters slot on clean exit (SIGTERM) - Reconnect loop for listener mode (stays alive between sessions) - --url, --slot-id, --fc-url, --width, --height, --fps-num/den, --source-type, --listen, --listen-port, --stream-key args - Emits format JSON to stderr on first frame - services/framecache/CMakeLists.txt: add net_ingest target - services/framecache/Dockerfile: copy net_ingest to runtime image - services/node-agent/index.js: - startNetIngest() / stopNetIngest(): lifecycle management per recorder - Spawns net_ingest before sidecar start for srt/rtmp sourceTypes - Injects FC_SLOT_ID=net-<containerId> into sidecar env - Sets IpcMode=host for network sidecars using framecache - Maps temp id → real containerId after container create - stopNetIngest() called on sidecar stop - NET_INGEST_BIN env var (default: docker exec framecache net_ingest) - services/capture/src/capture-manager.js: - _buildInputArgs srt/rtmp: framecache path when FC_SLOT_ID set (spawns fc_pipe, uses pipe:0 rawvideo input — same as SDI path) - Falls back to direct URL when FC_SLOT_ID not set (legacy path) - audioMap: network via framecache uses '0:a:0?' (video-only fc_pipe, no audio FIFO — audio-in-shm is roadmap) - HLS tee: sdiHlsDir covers network-via-framecache; legacy tee gated on !FC_SLOT_ID to avoid duplicate HLS outputs - fc_pipe piped to ffmpeg stdin for network framecache path - docker-compose.worker.yml: FC_URL + NET_INGEST_BIN in node-agent env
2026-06-03 11:37:17 -04:00
'-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 */
feat(framecache): phase 5 — network ingest (RTMP/SRT) via framecache - services/framecache/src/net_ingest.c: new network ingest process - Spawns ffmpeg to decode SRT/RTMP → raw UYVY422 stdout - Reads decoded frames and writes into framecache slot via shm - Registers slot with framecache HTTP API on startup - Deregisters slot on clean exit (SIGTERM) - Reconnect loop for listener mode (stays alive between sessions) - --url, --slot-id, --fc-url, --width, --height, --fps-num/den, --source-type, --listen, --listen-port, --stream-key args - Emits format JSON to stderr on first frame - services/framecache/CMakeLists.txt: add net_ingest target - services/framecache/Dockerfile: copy net_ingest to runtime image - services/node-agent/index.js: - startNetIngest() / stopNetIngest(): lifecycle management per recorder - Spawns net_ingest before sidecar start for srt/rtmp sourceTypes - Injects FC_SLOT_ID=net-<containerId> into sidecar env - Sets IpcMode=host for network sidecars using framecache - Maps temp id → real containerId after container create - stopNetIngest() called on sidecar stop - NET_INGEST_BIN env var (default: docker exec framecache net_ingest) - services/capture/src/capture-manager.js: - _buildInputArgs srt/rtmp: framecache path when FC_SLOT_ID set (spawns fc_pipe, uses pipe:0 rawvideo input — same as SDI path) - Falls back to direct URL when FC_SLOT_ID not set (legacy path) - audioMap: network via framecache uses '0:a:0?' (video-only fc_pipe, no audio FIFO — audio-in-shm is roadmap) - HLS tee: sdiHlsDir covers network-via-framecache; legacy tee gated on !FC_SLOT_ID to avoid duplicate HLS outputs - fc_pipe piped to ffmpeg stdin for network framecache path - docker-compose.worker.yml: FC_URL + NET_INGEST_BIN in node-agent env
2026-06-03 11:37:17 -04:00
_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 };
}
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
// ── Framecache path (primary for deltacast + blackmagic) ────────────────
//
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
// 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.
//
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
// fc_pipe is a small C helper that opens the framecache slot as a consumer
// and writes raw UYVY422 frames to stdout. capture-manager spawns it and
// pipes its stdout to ffmpeg as a rawvideo input — same pattern as the
// existing FIFO path, but with zero-copy shm reads and independent per-
// consumer cursors. Multiple fc_pipe instances on the same slot each get
// their own cursor, enabling simultaneous growing + proxy + HLS from one
// SDI input without any frame splitting.
//
// Audio stays on the named FIFO path (audio fan-out via shm is a roadmap
// item).
//
// node-agent ALWAYS injects FC_SLOT_ID for SDI sidecars (deterministic
// `deltacast-<board>-<port>` / `decklink-<node>-<dev>`), so this is the sole
// SDI path. The old FC_SLOT_ID-absent legacy FIFO fallback was removed once
// framecache became mandatory on every capture node.
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
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;
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
// Single-input AVI: fc_pipe muxes video+audio into ONE streaming AVI
// container on stdout. ffmpeg reads it as a SINGLE input (-f avi -i pipe:0),
// which eliminates the confirmed two-live-pipe deadlock (ffmpeg given a raw
// video pipe AND a separate live audio FIFO stalled forever probing input 0).
// No audio FIFO is created or used on this path anymore: audio rides inside
// the AVI as interleaved 01wb chunks, frame-coupled to each 00dc video chunk
// (both come from the SAME framecache ring entry in fc_pipe's read loop).
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
// Video dimensions and fps come from env vars injected by node-agent
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
// (populated from the bridge's format JSON on signal lock). fc_pipe also
// reads them from the slot header for the AVI header; these stay for logging.
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
const fcSize = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
const fcFps = process.env.DELTACAST_FRAMERATE || '60000/1001';
const fcInterlaced = process.env.DELTACAST_INTERLACED === '1';
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
console.log(`[framecache] slot=${slotId} size=${fcSize} fps=${fcFps} mode=avi (single-input video+audio, frame-coupled)`);
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
// Spawn fc_pipe in AVI mode: for each ring entry it emits a 00dc video chunk
// followed by a 01wb audio chunk into one AVI byte stream on stdout. ffmpeg
// reads that single stream and maps 0:v / 0:a. Because video and its audio
// are interleaved from the same ring entry, audio can never drift from video.
// argv: <slot_id> <wait_ms> --avi
const fcPipeProcess = spawn(fcPipeBin, [slotId, String(WAIT_MS), '--avi'], {
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
stdio: ['ignore', 'pipe', 'pipe'],
});
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
// Pause until piped to ffmpeg (avoids OS pipe-buffer fill stall - see
// the network path above for the full rationale).
fcPipeProcess.stdout.pause();
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
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: [
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
// fc_pipe stdout -> ffmpeg AVI input 0. ONE input carries both streams:
// 0:v = UYVY422 video (00dc chunks), 0:a = pcm_s16le audio (01wb chunks).
// The AVI demuxer reads the strf headers + the chunk stream with no index
// and no seeking, so streaming over a pipe is fine (RIFF/movi sizes are
// left as the streaming sentinel by fc_pipe).
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
'-thread_queue_size', '512',
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
'-f', 'avi',
// Optional fixed A/V trim (env AUDIO_OFFSET_MS); default empty = no shift.
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
// Applied as an input option so it shifts the AVI's audio relative to video.
...audioOffsetArgs(),
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
'-i', 'pipe:0',
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
],
isNetwork: false,
bridgeProcess: fcPipeProcess, /* capture-manager pipes this to ffmpeg stdin */
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
audioFifo: null, /* no separate audio FIFO on the AVI path */
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
interlaced: fcInterlaced,
fix(framecache): single-input streaming AVI muxer in fc_pipe (kills 2-pipe ffmpeg deadlock) fc_pipe now muxes video + that frame's embedded audio into ONE streaming AVI container on stdout, so ffmpeg reads a SINGLE input (-f avi -i pipe:0) instead of a raw video pipe + a separate live audio FIFO. The two-live-pipe design deadlocked ffmpeg (it stalled forever probing input 0); a single interleaved stream removes that failure mode entirely. fc_pipe.c: - New AVI mode (argv[3] == "--avi"/"avi"). Writes RIFF('AVI ') + LIST(hdrl) { avih + strl(vids: strh+BITMAPINFOHEADER UYVY 16bpp) + strl(auds: strh+WAVEFORMATEX PCM s16le 48k 2ch) } + LIST(movi) once, then per ring entry a '00dc' video chunk followed by a '01wb' audio chunk (LE sizes, even-pad). RIFF/movi sizes use the 0x7FFFFFFF streaming sentinel (pipe is unseekable); dwFlags has NO index bits. Frame-coupled by construction: both chunks come from the SAME ring entry in one read-loop iteration. - dwScale/dwRate = fps_den/fps_num (video) and nBlockAlign/nAvgBytesPerSec (audio). If a frame has audio_size 0, emits one frame-interval of silence (round(48000*fps_den/fps_num) samples) so the audio timeline tracks video and ffmpeg never starves on the audio demuxer. - Legacy raw video-only mode retained when no avi flag is given. The old split-stdout/audio-FIFO threaded path is removed (it was the deadlock). fc_client.{h,c}: - Add fc_consumer_info() / fc_stream_info_t to expose the slot header's width/height/fps/audio params to fc_pipe for the AVI header. capture-manager.js (_buildInputArgs deltacast/sdi framecache branch): - Spawn fc_pipe with "--avi" (no audio FIFO). Remove the mkfifo + audio-FIFO creation for this path. - inputArgs: ONE input -thread_queue_size 512 -f avi [AUDIO_OFFSET_MS] -i pipe:0 (was: -f rawvideo -i pipe:0 AND -f s16le -ar 48000 -ac 2 -i <fifo>). - audioInputIndex 0, audioFifo null. Growing VC-3/HEVC builders already map [0:v] and audioMap 0:a:0?; with one AVI input that resolves to 0:v / 0:a. Validated on zampp3 against the LIVE deltacast-0-0 slot: fc_pipe --avi | ffmpeg -f avi -i pipe:0 -> dnxhd/pcm_s24le MXF gives 360 video / 360 audio packets in 6.006s (no stall at 2 frames). A synthetic 1 kHz sine slot through the same path yields mean_volume -9 dB / max -6 dB, proving the muxer carries real audio end-to-end (the live SDI input currently carries no embedded audio, so the bridge's silence fallback reads -91 dB — upstream of the muxer).
2026-06-05 10:06:35 -04:00
audioInputIndex: 0, /* audio is inside the single AVI input (0:a) */
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
_fcPipeProcess: fcPipeProcess, /* stored for clean stop */
};
}
// Default: SDI via a pluggable source backend (issue #168). The backend
// selection defaults to `blackmagic` (DeckLink) so existing SDI recorders
// behave exactly as before. Deltacast/AJA backends throw until their
// hardware/SDK plumbing lands.
const backend = sourceBackends[sourceBackend];
if (!backend) {
throw new Error(`Unknown source backend "${sourceBackend}" — expected one of: ${Object.keys(sourceBackends).join(', ')}`);
}
return await backend.buildInput({ device });
}
/**
* Build the single-ffmpeg argv for a GROWING VC-3 / DNxHD master in MXF OP1a.
*
* KEY: ffmpeg's native MXF muxer writes a frame-wrapped OP1a whose BODY grows
* readably while still being written proven on-node: the partial file opens
* as 'mxf' and decodes 0 errors at t=4s/6s mid-write, and finalizes with a
* valid Duration + footer on clean stop. This is exactly how vMix records
* growing VC-3 that Premiere imports live. So NO raw2bmx, NO FIFO orchestrator,
* NO footer-finalize ordering one ffmpeg writes the MXF straight to the share.
*
* Valid VC-3 profiles at 1080p59.94 (8-bit 4:2:2, confirmed on-node):
* 220 Mbps -> classic DNxHD (essence VC3_1080p_1238), highest quality.
* 90 Mbps -> DNxHR-LB-class, lighter storage. Both grow + import in Premiere
* via the ffmpeg-direct MXF path (raw2bmx is NOT involved here, so
* the DNxHR-90 profile is fine even though raw2bmx can't parse it).
* Bitrate comes from `vc3Bitrate` ('220M' default | '90M'). On SIGINT ffmpeg
* flushes the MXF footer cleanly, so the normal SIGINT stop works here.
*/
_buildGrowingVc3Mxf({ inputArgs, framerate, audioChannels, outPath, audioMap = '0:a:0?', hlsDir = null, videoCodec = 'h264_nvenc', interlaced = false, vc3Bitrate = '90M' }) {
const ach = audioChannels ? Number(audioChannels) : 2;
const vb = (vc3Bitrate === '90M' || vc3Bitrate === '220M') ? vc3Bitrate : '90M';
const args = ['-y', '-hide_banner', '-loglevel', 'warning', '-stats', ...inputArgs];
// Deinterlace (SDI) then split: master VC-3 + optional HLS preview tap.
const filterComplex = hlsDir
? (interlaced ? '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]' : '[0:v]split=2[vhi][vlo]')
: (interlaced ? '[0:v]yadif=mode=1:deint=1,split=1[vhi]' : '[0:v]split=1[vhi]');
args.push('-filter_complex', filterComplex);
// (a) VC-3/DNxHD master (8-bit 4:2:2) -> MXF OP1a, growing-readable.
// `-threads 16 -thread_type slice`: CRITICAL for the first ~10s. With ffmpeg's
// DEFAULT frame-threading, dnxhd buffers a long pipeline before output starts
// — it runs at ~0.27x for the first few seconds, so the fc_pipe ring overflows
// and ~344 startup frames are DROPPED (spotty audio+video for ~10s). Explicit
// slice threading makes dnxhd encode >= realtime from frame 1 (measured 1.3x
// at start vs 0.27x), eliminating the startup backlog and the dropped frames.
args.push('-map', '[vhi]',
'-c:v', 'dnxhd', '-threads', '32', '-thread_type', 'slice',
'-b:v', vb, '-pix_fmt', 'yuv422p',
'-r', framerate || '60000/1001',
'-map', audioMap, '-c:a', 'pcm_s24le', '-ar', '48000', '-ac', String(ach),
'-f', 'mxf', outPath);
// (b) optional H.264 HLS preview -> second output (keeps the UI monitor live).
if (hlsDir) {
args.push('-map', '[vlo]', '-map', audioMap,
...buildHlsVideoArgs(videoCodec, framerate),
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
'-hls_flags', 'delete_segments+append_list+omit_endlist',
'-hls_segment_filename', `${hlsDir}/seg-%05d.ts`,
`${hlsDir}/index.m3u8`);
}
return args;
}
/**
* Start a new capture session.
*
* Codec parameters all have sensible defaults so legacy callers (no codec
* args) still produce ProRes HQ master + H.264 proxy.
*/
async start({
assetId,
projectId,
binId,
clipName,
device,
// Deltacast: one board (index 0) with 8 channels. `port` selects the
// channel; `board` selects the physical board (default 0).
port,
board,
sourceType = 'sdi',
// Source-backend selection for SDI capture (issue #168). Defaults to
// `blackmagic` (DeckLink) so existing recorders are unaffected.
sourceBackend = 'blackmagic',
sourceUrl,
listen = false,
listenPort,
streamKey,
// ── Recording codec ─────────────────────────────────────────────
videoCodec = 'prores_hq',
videoBitrate = null,
framerate = null,
audioCodec = 'pcm_s24le',
audioBitrate = null,
audioChannels = 2,
container = 'mov',
// ── Proxy codec ─────────────────────────────────────────────────
proxyEnabled = true,
proxyVideoCodec = 'h264',
proxyVideoBitrate = '8M',
proxyFramerate = null,
proxyAudioCodec = 'aac',
proxyAudioBitrate = '192k',
proxyAudioChannels = 2,
proxyContainer = 'mp4',
}) {
this._assetIdForHls = assetId || null;
if (this.state.recording) {
throw new Error('Capture already in progress');
}
// Stop the idle confidence monitor BEFORE touching the FIFO. A second
// reader on the video FIFO halves the capture rate (~29 fps) and desyncs
// audio — so the monitor must fully release the FIFO before recording.
this.stopIdlePreview();
const sessionId = uuidv4();
const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4';
// Growing-files: write master to the SMB share instead of streaming to S3.
// Path is relative to the container's GROWING_PATH mount.
//
// Approach A: if a CIFS source is configured, mount it now. A mount failure
// is non-fatal — we fall back to S3 streaming so the recording is never
// lost.
// Read growing flags FRESH from env at record time — standby sidecars boot
// with GROWING_ENABLED=false and receive the real value per-session over
// /capture/start (capture.js sets process.env before this runs). The old
// module-level `const GROWING_ENABLED` / `GROWING_SMB_MOUNT` captured the
// empty boot values, so growing never engaged and every "growing" record
// silently produced HEVC/S3 instead of the VC-3/DNxHD MXF.
let growingActive = process.env.GROWING_ENABLED === 'true';
if (growingActive && growingSmbConfig().mount) {
if (!mountGrowingShare()) growingActive = false; // fall back to S3
}
// Growing master is always VC-3/DNxHD in MXF OP1a, written directly by
// ffmpeg's native MXF muxer (see _buildGrowingVc3Mxf), regardless of the
// recorder's configured container — so it gets a .mxf extension.
const _growCodec = growingActive ? growingCodec() : null;
const _growExt = _growCodec ? growingExtFor(_growCodec) : GROWING_EXT;
const growingPath = growingActive
? `${GROWING_PATH}/${projectId}/${clipName}.${_growExt}`
: null;
// hiresKey MUST match the actual master format/destination:
// - growing active → the master is a growing OP1a MXF on the share; the
// promotion worker uploads it to this key, so it has the .mxf extension.
// (A stale .mov key here would make the proxy job download a nonexistent
// object → "unable to open the file on disk".)
// - growing fell back to S3 → the normal container extension.
const hiresExt = growingPath ? _growExt : (CONTAINER_EXT[container] || 'mov');
const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`;
if (growingPath) {
try { mkdirSync(dirname(growingPath), { recursive: true }); }
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
}
// DeckLink hardware does NOT support concurrent capture from the same port.
// Opening a second ffmpeg process on the same DeckLink input while the first
// is already capturing causes "Cannot Autodetect input stream or No signal"
// on the second process — making the proxy empty and potentially crashing the
// container before the hires upload completes.
//
// Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ
// worker generate the proxy from the hires master after the recording stops.
// The stop handler sets needsProxy=true so the worker picks it up.
const proxyKey = null;
this._sessionIdForBridge = sessionId;
const { inputArgs, isNetwork, bridgeProcess = null, audioFifo = null, interlaced = false, audioInputIndex = 0,
} = await this._buildInputArgs({
sourceType, sourceBackend, device, port, board, sourceUrl, listen, listenPort, streamKey,
});
// ── Pre-roll + A/V alignment ─────────────────────────────────────────────
// The pre-roll drains the VIDEO pipe (fc_pipe) to discard unstable startup
// frames. In STANDBY the framecache slot is already warm, so there are no
// unstable frames — skip the video drain (draining only video while audio
// keeps buffering is exactly what offset the streams, giving "silent first
// second then clean").
if (bridgeProcess && !_standbyMode
&& (sourceType === 'deltacast' || sourceType === 'blackmagic' || sourceType === 'sdi')) {
console.log(`[capture] pre-rolling: discarding ${PRE_ROLL_SECONDS}s of frames`);
bridgeProcess.stdout.on('data', () => {});
await new Promise(r => setTimeout(r, PRE_ROLL_SECONDS * 1000));
bridgeProcess.stdout.removeAllListeners('data');
console.log(`[capture] pre-roll complete.`);
}
fix(framecache): frame-coupled audio — video+audio in ONE ring entry Re-engineer the framecache so each video frame carries its own SDI-embedded audio through ONE transport, eliminating the "audio ahead of video" offset at the root: there is no longer a second independent audio buffer/FIFO that can race ahead of video. slot.h (FC_VERSION 1 -> 2): - Per ring entry data region is now [video frame_size][audio FC_MAX_AUDIO_BYTES]. - fc_frame_t: the former _pad u32 is REUSED as audio_size (header still 24B). - Header gains audio_max_bytes / audio_rate / audio_channels (self-describing). - New fc_entry_stride()/fc_frame_audio() helpers; shm size includes audio. - Readers/writer check version == FC_VERSION and FAIL SAFE on mismatch so an old reader against a new writer (or vice-versa) refuses rather than misparses. slot.c: populate audio header fields; add fc_slot_write_av(); version gate in open. fc_client.[ch]: fc_frame_ref_t gains audio/audio_size; copy buffer holds video+audio; both copied from the SAME entry in one read -> frame-locked. fc_pipe.c: now <slot_id> <wait_ms> <audio_fifo_path>; per ring entry writes video -> stdout AND that frame's audio -> the audio FIFO IN LOCKSTEP from one cursor read (no second buffer to drift). Auto-reattaches FIFO on EPIPE. deltacast-bridge: - SILENT-AUDIO FIX: audio_extract_init now configures ONLY pAudioChannels[0] of group 0 as one stereo pair (Mode=STEREO, BufferFormat=AF_16, pData=buf), leaving pAudioChannels[1] ZEROED, exactly like Deltacast's own FFmpeg fork (libavdevice/videomaster_common.c init_audio_info). The prior JOINED code ALSO set channel[1].Mode/BufferFormat=STEREO/AF_16, declaring a second pair the signal does not carry -> VHD_SlotExtractAudio returned zero samples -> -91 dB silent audio. DataSize is (re)set to capacity before each extract. - VHD_SDI_SP_INTERFACE now set from the channel-detected interface (VHD_SDI_CP_INTERFACE) before StartStream, per the same fork — required for embedded-audio extraction on JOINED SDI streams. - fc_writer.[ch]: add fc_writer_write_av(); struct/stride bumped to v2. - video_thread (framecache path) extracts each frame's audio from the SAME locked JOINED slot and writes BOTH via fc_writer_write_av. Silence fallback at the source: a frame with no embedded audio gets one frame-interval of silence so the audio timeline length always equals the video timeline length. - The separate audio FIFO + audio_thread + apcm ring are retained ONLY for the legacy (-DLEGACY_FIFO / framecache-unreachable) fallback; on the primary framecache path the bridge no longer owns the audio FIFO. capture-manager.js: deltacast/sdi framecache branch now CREATES the audio FIFO and passes its path to fc_pipe (argv[3]); ffmpeg keeps two raw inputs (rawvideo pipe:0 + s16le 48k input 1) but both are now fed frame-locked from the same ring entry. Stale-audio pre-flush retained as harmless safety. All changes versioned; mismatched binaries refuse to attach (fail safe).
2026-06-05 08:46:22 -04:00
// FLUSH STALE AUDIO immediately before ffmpeg opens the FIFO.
//
// With frame-coupled audio (FC_VERSION 2) fc_pipe only writes the audio FIFO
// once a reader attaches, and each audio chunk is bound to its video frame in
// the same ring entry — so there is normally NO stale standby backlog. This
// drain is retained as a harmless belt-and-suspenders: it reads whatever (if
// anything) is buffered and returns immediately on EAGAIN, guaranteeing the
// record ffmpeg attaches at the live edge. fc_pipe reattaches automatically
// if it briefly saw this drain as its reader.
if (audioFifo) {
try {
const fsSync = await import('node:fs');
const fd = fsSync.openSync(audioFifo, fsSync.constants.O_RDONLY | fsSync.constants.O_NONBLOCK);
const tmp = Buffer.allocUnsafe(1 << 20);
let drained = 0;
for (;;) {
let n = 0;
try { n = fsSync.readSync(fd, tmp, 0, tmp.length, null); }
catch (e) { if (e.code === 'EAGAIN') break; throw e; }
if (n <= 0) break;
drained += n;
}
fsSync.closeSync(fd);
console.log(`[capture] flushed ${drained} bytes of stale standby audio before record`);
} catch (e) {
console.warn(`[capture] audio FIFO pre-flush skipped: ${e.message}`);
}
}
const startedAt = new Date().toISOString();
const recordingStartedAt = Date.now();
// Audio input index is returned EXPLICITLY by _buildInputArgs (audioInputIndex)
// rather than guessed from sourceType/FC_SLOT_ID — that guess was wrong for
// the legacy deltacast FIFO path (which has audio at input 1 but no FC_SLOT_ID),
// silently dropping audio. Each return path now declares its own audio input:
// - deltacast/blackmagic via framecache: audio FIFO = input 1
// - legacy deltacast FIFO: audio FIFO = input 1
// - network (framecache or legacy) + DeckLink-backend SDI: audio in input 0
const audioMap = `${audioInputIndex}:a:0?`;
// Non-growing master: ffmpeg muxes the finalized MOV directly. Growing
// master: _buildGrowingVc3Mxf builds its own ffmpeg argv below, so we don't
// build ffmpeg codec args here for it.
const hiresCodecArgs = growingPath ? null : buildEncodeArgs({
codec: videoCodec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels,
container,
isNetwork,
isProxy: false,
});
if (hiresCodecArgs) console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
const isInterlacedSource = sourceType === 'sdi'
|| (sourceType === 'deltacast' && interlaced)
|| ((sourceType === 'blackmagic') && interlaced);
const sdiFilterArgs = isInterlacedSource ? ['-vf', 'yadif=mode=1:deint=1'] : [];
// Master output destination.
//
// - Growing-files on → the growing VC-3/DNxHD OP1a MXF is written directly
// to the SMB share by a single ffmpeg (see _buildGrowingVc3Mxf), which
// also taps the HLS preview.
//
// - Growing-files off → ffmpeg writes fragmented MOV to pipe:1 (stdout),
// which is piped directly into a multipart S3 upload. No local temp file,
// no worker disk consumed. Premiere Pro 25.x handles fragmented MOV natively.
const hiresOutput = growingPath ? growingPath : 'pipe:1';
// pipe:1 = ffmpeg stdout → S3 stream. bridgeProcess (fc_pipe) uses stdin.
const hiresStdio = bridgeProcess ? ['pipe', 'pipe', 'pipe'] : ['ignore', 'pipe', 'pipe'];
feat(framecache): phase 5 — network ingest (RTMP/SRT) via framecache - services/framecache/src/net_ingest.c: new network ingest process - Spawns ffmpeg to decode SRT/RTMP → raw UYVY422 stdout - Reads decoded frames and writes into framecache slot via shm - Registers slot with framecache HTTP API on startup - Deregisters slot on clean exit (SIGTERM) - Reconnect loop for listener mode (stays alive between sessions) - --url, --slot-id, --fc-url, --width, --height, --fps-num/den, --source-type, --listen, --listen-port, --stream-key args - Emits format JSON to stderr on first frame - services/framecache/CMakeLists.txt: add net_ingest target - services/framecache/Dockerfile: copy net_ingest to runtime image - services/node-agent/index.js: - startNetIngest() / stopNetIngest(): lifecycle management per recorder - Spawns net_ingest before sidecar start for srt/rtmp sourceTypes - Injects FC_SLOT_ID=net-<containerId> into sidecar env - Sets IpcMode=host for network sidecars using framecache - Maps temp id → real containerId after container create - stopNetIngest() called on sidecar stop - NET_INGEST_BIN env var (default: docker exec framecache net_ingest) - services/capture/src/capture-manager.js: - _buildInputArgs srt/rtmp: framecache path when FC_SLOT_ID set (spawns fc_pipe, uses pipe:0 rawvideo input — same as SDI path) - Falls back to direct URL when FC_SLOT_ID not set (legacy path) - audioMap: network via framecache uses '0:a:0?' (video-only fc_pipe, no audio FIFO — audio-in-shm is roadmap) - HLS tee: sdiHlsDir covers network-via-framecache; legacy tee gated on !FC_SLOT_ID to avoid duplicate HLS outputs - fc_pipe piped to ffmpeg stdin for network framecache path - docker-compose.worker.yml: FC_URL + NET_INGEST_BIN in node-agent env
2026-06-03 11:37:17 -04:00
// 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;
feat(framecache): phase 5 — network ingest (RTMP/SRT) via framecache - services/framecache/src/net_ingest.c: new network ingest process - Spawns ffmpeg to decode SRT/RTMP → raw UYVY422 stdout - Reads decoded frames and writes into framecache slot via shm - Registers slot with framecache HTTP API on startup - Deregisters slot on clean exit (SIGTERM) - Reconnect loop for listener mode (stays alive between sessions) - --url, --slot-id, --fc-url, --width, --height, --fps-num/den, --source-type, --listen, --listen-port, --stream-key args - Emits format JSON to stderr on first frame - services/framecache/CMakeLists.txt: add net_ingest target - services/framecache/Dockerfile: copy net_ingest to runtime image - services/node-agent/index.js: - startNetIngest() / stopNetIngest(): lifecycle management per recorder - Spawns net_ingest before sidecar start for srt/rtmp sourceTypes - Injects FC_SLOT_ID=net-<containerId> into sidecar env - Sets IpcMode=host for network sidecars using framecache - Maps temp id → real containerId after container create - stopNetIngest() called on sidecar stop - NET_INGEST_BIN env var (default: docker exec framecache net_ingest) - services/capture/src/capture-manager.js: - _buildInputArgs srt/rtmp: framecache path when FC_SLOT_ID set (spawns fc_pipe, uses pipe:0 rawvideo input — same as SDI path) - Falls back to direct URL when FC_SLOT_ID not set (legacy path) - audioMap: network via framecache uses '0:a:0?' (video-only fc_pipe, no audio FIFO — audio-in-shm is roadmap) - HLS tee: sdiHlsDir covers network-via-framecache; legacy tee gated on !FC_SLOT_ID to avoid duplicate HLS outputs - fc_pipe piped to ffmpeg stdin for network framecache path - docker-compose.worker.yml: FC_URL + NET_INGEST_BIN in node-agent env
2026-06-03 11:37:17 -04:00
if ((sourceType === 'sdi' || sourceType === 'deltacast' || sourceType === 'blackmagic'
|| (_viaFcPipeHls && (sourceType === 'srt' || sourceType === 'rtmp')))
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
&& this._assetIdForHls) {
const fsMod = await import('node:fs');
sdiHlsDir = '/live/' + this._assetIdForHls;
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
}
let hiresProcess;
if (growingPath) {
// ── GROWING master: VC-3 / DNxHD -> MXF OP1a (ffmpeg-native) ──────────
// Single ffmpeg, NO raw2bmx / NO FIFO orchestrator. Video from fc_pipe
// stdin (pipe:0). ffmpeg's MXF muxer writes a frame-wrapped OP1a whose body
// grows readably mid-write — USER-CONFIRMED: imports + grows live in Adobe
// Premiere (matches the vMix edit-while-record workflow). _growCodec is
// 'vc3_220' or 'vc3_90'; growingVc3Bitrate() maps it to the dnxhd -b:v.
// SIGINT flushes the MXF footer cleanly, so the standard SIGINT stop applies.
const vc3Args = this._buildGrowingVc3Mxf({
inputArgs, framerate, audioChannels,
outPath: growingPath, audioMap,
hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null,
videoCodec, interlaced: isInterlacedSource,
vc3Bitrate: growingVc3Bitrate(_growCodec),
});
console.log(`[capture] growing master via VC-3/DNxHD MXF (ffmpeg-native, ${growingVc3Bitrate(_growCodec)}); args=` + vc3Args.length);
hiresProcess = spawn('ffmpeg', vc3Args, {
stdio: bridgeProcess ? ['pipe', 'ignore', 'pipe'] : ['ignore', 'ignore', 'pipe'],
detached: true,
});
if (bridgeProcess && bridgeProcess.stdout && hiresProcess.stdin) {
hiresProcess.stdin.on('error', (e) => {
if (e && e.code !== 'EPIPE') console.warn(`[capture] vc3 growing stdin error: ${e.message}`);
});
bridgeProcess.stdout.on('error', (e) => {
console.warn(`[capture] fc_pipe stdout error: ${e && e.message}`);
});
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
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;
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
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,
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
// 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',
];
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
console.log('[HLS] SDI/framecache preview as 2nd output -> ' + sdiHlsDir);
} else {
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
}
hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
// 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),
};
feat(framecache): phase 5 — network ingest (RTMP/SRT) via framecache - services/framecache/src/net_ingest.c: new network ingest process - Spawns ffmpeg to decode SRT/RTMP → raw UYVY422 stdout - Reads decoded frames and writes into framecache slot via shm - Registers slot with framecache HTTP API on startup - Deregisters slot on clean exit (SIGTERM) - Reconnect loop for listener mode (stays alive between sessions) - --url, --slot-id, --fc-url, --width, --height, --fps-num/den, --source-type, --listen, --listen-port, --stream-key args - Emits format JSON to stderr on first frame - services/framecache/CMakeLists.txt: add net_ingest target - services/framecache/Dockerfile: copy net_ingest to runtime image - services/node-agent/index.js: - startNetIngest() / stopNetIngest(): lifecycle management per recorder - Spawns net_ingest before sidecar start for srt/rtmp sourceTypes - Injects FC_SLOT_ID=net-<containerId> into sidecar env - Sets IpcMode=host for network sidecars using framecache - Maps temp id → real containerId after container create - stopNetIngest() called on sidecar stop - NET_INGEST_BIN env var (default: docker exec framecache net_ingest) - services/capture/src/capture-manager.js: - _buildInputArgs srt/rtmp: framecache path when FC_SLOT_ID set (spawns fc_pipe, uses pipe:0 rawvideo input — same as SDI path) - Falls back to direct URL when FC_SLOT_ID not set (legacy path) - audioMap: network via framecache uses '0:a:0?' (video-only fc_pipe, no audio FIFO — audio-in-shm is roadmap) - HLS tee: sdiHlsDir covers network-via-framecache; legacy tee gated on !FC_SLOT_ID to avoid duplicate HLS outputs - fc_pipe piped to ffmpeg stdin for network framecache path - docker-compose.worker.yml: FC_URL + NET_INGEST_BIN in node-agent env
2026-06-03 11:37:17 -04:00
// ── 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;
feat(framecache): phase 5 — network ingest (RTMP/SRT) via framecache - services/framecache/src/net_ingest.c: new network ingest process - Spawns ffmpeg to decode SRT/RTMP → raw UYVY422 stdout - Reads decoded frames and writes into framecache slot via shm - Registers slot with framecache HTTP API on startup - Deregisters slot on clean exit (SIGTERM) - Reconnect loop for listener mode (stays alive between sessions) - --url, --slot-id, --fc-url, --width, --height, --fps-num/den, --source-type, --listen, --listen-port, --stream-key args - Emits format JSON to stderr on first frame - services/framecache/CMakeLists.txt: add net_ingest target - services/framecache/Dockerfile: copy net_ingest to runtime image - services/node-agent/index.js: - startNetIngest() / stopNetIngest(): lifecycle management per recorder - Spawns net_ingest before sidecar start for srt/rtmp sourceTypes - Injects FC_SLOT_ID=net-<containerId> into sidecar env - Sets IpcMode=host for network sidecars using framecache - Maps temp id → real containerId after container create - stopNetIngest() called on sidecar stop - NET_INGEST_BIN env var (default: docker exec framecache net_ingest) - services/capture/src/capture-manager.js: - _buildInputArgs srt/rtmp: framecache path when FC_SLOT_ID set (spawns fc_pipe, uses pipe:0 rawvideo input — same as SDI path) - Falls back to direct URL when FC_SLOT_ID not set (legacy path) - audioMap: network via framecache uses '0:a:0?' (video-only fc_pipe, no audio FIFO — audio-in-shm is roadmap) - HLS tee: sdiHlsDir covers network-via-framecache; legacy tee gated on !FC_SLOT_ID to avoid duplicate HLS outputs - fc_pipe piped to ffmpeg stdin for network framecache path - docker-compose.worker.yml: FC_URL + NET_INGEST_BIN in node-agent env
2026-06-03 11:37:17 -04:00
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;
feat(framecache): phase 5 — network ingest (RTMP/SRT) via framecache - services/framecache/src/net_ingest.c: new network ingest process - Spawns ffmpeg to decode SRT/RTMP → raw UYVY422 stdout - Reads decoded frames and writes into framecache slot via shm - Registers slot with framecache HTTP API on startup - Deregisters slot on clean exit (SIGTERM) - Reconnect loop for listener mode (stays alive between sessions) - --url, --slot-id, --fc-url, --width, --height, --fps-num/den, --source-type, --listen, --listen-port, --stream-key args - Emits format JSON to stderr on first frame - services/framecache/CMakeLists.txt: add net_ingest target - services/framecache/Dockerfile: copy net_ingest to runtime image - services/node-agent/index.js: - startNetIngest() / stopNetIngest(): lifecycle management per recorder - Spawns net_ingest before sidecar start for srt/rtmp sourceTypes - Injects FC_SLOT_ID=net-<containerId> into sidecar env - Sets IpcMode=host for network sidecars using framecache - Maps temp id → real containerId after container create - stopNetIngest() called on sidecar stop - NET_INGEST_BIN env var (default: docker exec framecache net_ingest) - services/capture/src/capture-manager.js: - _buildInputArgs srt/rtmp: framecache path when FC_SLOT_ID set (spawns fc_pipe, uses pipe:0 rawvideo input — same as SDI path) - Falls back to direct URL when FC_SLOT_ID not set (legacy path) - audioMap: network via framecache uses '0:a:0?' (video-only fc_pipe, no audio FIFO — audio-in-shm is roadmap) - HLS tee: sdiHlsDir covers network-via-framecache; legacy tee gated on !FC_SLOT_ID to avoid duplicate HLS outputs - fc_pipe piped to ffmpeg stdin for network framecache path - docker-compose.worker.yml: FC_URL + NET_INGEST_BIN in node-agent env
2026-06-03 11:37:17 -04:00
console.log('[HLS] legacy-net tee started -> ' + hlsDir);
} catch (err) {
console.error('[HLS] tee failed:', err.message);
}
}
hiresProcess.stderr.on('data', (data) => {
const text = data.toString();
console.error(`[HIRES] ${text}`);
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
if (m) {
this.state.framesReceived = parseInt(m[1], 10);
this.state.lastFrameAt = new Date().toISOString();
// Use ffmpeg's own rolling fps value — it is a short-window average
// computed by ffmpeg itself and correctly reflects the true encode rate.
// The previous frame/elapsed cumulative calculation dragged low during
// startup.
const ffmpegFps = parseFloat(m[2]);
if (ffmpegFps > 0) this.state.currentFps = Math.round(ffmpegFps * 100) / 100;
}
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
this.state.lastError = text.trim().slice(0, 240);
}
});
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
// DeckLink hardware does not support two concurrent readers on the same port.
this.state.recording = true;
this.state.sessionId = sessionId;
this.state.processes = processes;
this.state.framesReceived = 0;
this.state.currentFps = 0;
this.state.lastFrameAt = null;
this.state.lastError = null;
this.state.recordingStartedAt = Date.now();
this.state.currentSession = {
sessionId,
projectId,
binId,
clipName,
device,
sourceType,
sourceUrl,
assetId,
hiresKey,
proxyKey,
growingPath,
growingCodec: growingPath ? _growCodec : null, // growing codec: 'vc3_90' | 'vc3_220'
audioFifo,
startedAt,
duration: 0,
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
_fcPipeProcess: bridgeProcess || null, /* fc_pipe process, if framecache path used */
uploads,
codecs: {
videoCodec, videoBitrate, framerate,
audioCodec, audioBitrate, audioChannels, container,
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
},
};
// Fire-and-forget: grab the first frame for the live poster thumbnail.
// Only for sources that produce an HLS dir (sdi/deltacast); never blocks start().
if (sdiHlsDir && assetId) {
this._publishLiveThumbnail({ assetId, hlsDir: sdiHlsDir }).catch(() => {});
}
return this._formatSessionResponse();
}
// ── Idle confidence monitor ────────────────────────────────────────────
// A low-rate (1 fps) single-JPEG confidence snapshot for the recorder tile
// when the recorder is NOT actively recording.
//
// CRITICAL: this must NEVER read the video FIFO while a recording is active.
// A second continuous reader on the same /dev/shm/deltacast/video-N.fifo
// splits the frames between the two readers, halving the capture rate to
// ~29 fps (the root cause of the out-of-sync / fast-playback bug). So the
// monitor:
// 1. runs ONLY when this.state.recording === false
// 2. opens the FIFO, grabs ONE frame, scales to a small JPEG, exits
// 3. sleeps 1s, repeats — yielding the FIFO completely between grabs
// 4. is fully stopped the instant a recording starts (see start())
async startIdlePreview() {
if (this._previewTimer || this._previewProc) return; // already running
if (this.state.recording) return; // never run during an active recording
const sourceType = process.env.SOURCE_TYPE;
const recorderId = process.env.RECORDER_ID;
if (!recorderId || !['deltacast', 'sdi'].includes(sourceType)) return;
if (sourceType !== 'deltacast') return; // SDI/blackmagic snapshot TBD
const previewDir = `/live/preview-${recorderId}`;
try { await fs.promises.mkdir(previewDir, { recursive: true }); } catch (_) {}
const size = process.env.DELTACAST_VIDEO_SIZE || '1920x1080';
let cfg = {};
try { cfg = JSON.parse(process.env.SOURCE_CONFIG || '{}'); } catch (_) {}
const port = cfg.port ?? 0;
const videoFifo = (process.env.DELTACAST_PIPE_DIR || '/dev/shm/deltacast') + `/video-${port}.fifo`;
const outJpg = previewDir + '/frame.jpg';
const tmpJpg = previewDir + '/frame.tmp.jpg';
this._previewStop = false;
console.log('[preview] starting 1fps confidence monitor for', recorderId);
const grabOnce = () => new Promise((resolve) => {
// Never compete with an active recording.
if (this._previewStop || this.state.recording) return resolve();
// -frames:v 1 reads exactly ONE frame then exits, releasing the FIFO.
// Read-rate is capped by -readrate 1 so the single-frame read consumes
// ~1 frame worth of FIFO data, not a burst.
const ff = spawn('ffmpeg', [
'-y',
'-f', 'rawvideo', '-pix_fmt', 'uyvy422', '-s', size,
'-i', videoFifo,
'-frames:v', '1',
'-vf', 'scale=480:-2',
'-q:v', '5',
tmpJpg,
], { stdio: ['ignore', 'ignore', 'ignore'] });
this._previewProc = ff;
const killTimer = setTimeout(() => { try { ff.kill('SIGKILL'); } catch (_) {} }, 4000);
ff.on('exit', () => {
clearTimeout(killTimer);
this._previewProc = null;
// Atomic-ish swap so the served frame is never half-written.
fs.rename(tmpJpg, outJpg, () => resolve());
});
ff.on('error', () => { clearTimeout(killTimer); this._previewProc = null; resolve(); });
});
const loop = async () => {
while (!this._previewStop) {
await grabOnce();
if (this._previewStop) break;
await new Promise(r => { this._previewTimer = setTimeout(r, 1000); });
}
};
loop();
}
stopIdlePreview() {
this._previewStop = true;
if (this._previewTimer) { clearTimeout(this._previewTimer); this._previewTimer = null; }
if (this._previewProc) {
try { this._previewProc.kill('SIGKILL'); } catch (_) {}
this._previewProc = null;
}
}
async stop(sessionId) {
if (!this.state.recording || this.state.sessionId !== sessionId) {
throw new Error('No active capture session or session ID mismatch');
}
this.stopIdlePreview();
const { processes, currentSession } = this.state;
const isGrowing = !!currentSession.growingPath;
// Send SIGINT and WAIT for the master writer to exit cleanly.
// - Non-growing: SIGINT flushes ffmpeg's MOV trailer (the moov atom with
// full sample tables). Uploading before finalize → "moov atom not found".
// - Growing (VC-3/DNxHD MXF): `processes.hires` is a single ffmpeg writing
// the OP1a directly. A plain SIGINT makes ffmpeg flush the MXF footer
// (Duration + index) cleanly, exactly like the non-growing MOV trailer.
// Awaiting it guarantees the finalized, valid MXF is on the share before
// the promotion worker uploads it. The footer flush of a long recording
// can take a moment, so the growing safety-net timeout is more generous.
const finalizeTimeoutMs = isGrowing ? 60000 : 15000;
const waitExit = (proc) => new Promise((resolve) => {
if (!proc || proc.exitCode !== null || proc.signalCode !== null) return resolve();
let done = false;
const finish = () => { if (!done) { done = true; resolve(); } };
proc.once('exit', finish);
// Safety net: don't hang stop() forever if the writer refuses to exit.
setTimeout(() => {
try {
// The growing ffmpeg is spawned detached (its own process group) →
// SIGKILL the whole group; otherwise just the process.
if (isGrowing && proc.pid) { try { process.kill(-proc.pid, 'SIGKILL'); } catch (_) {} }
proc.kill('SIGKILL');
} catch (_) {}
finish();
}, finalizeTimeoutMs);
});
// Stop: a plain SIGINT flushes the container footer/trailer cleanly for both
// the non-growing master and the single-ffmpeg VC-3/DNxHD growing MXF writer.
if (processes.hires) processes.hires.kill('SIGINT');
if (processes.proxy) processes.proxy.kill('SIGINT');
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
if (currentSession._fcPipeProcess) {
try { currentSession._fcPipeProcess.kill('SIGTERM'); } catch (_) {}
feat(framecache): phase 4 — capture-manager reads from framecache - services/framecache/client/fc_pipe.c: new slot→stdout pipe adapter - Opens framecache slot as consumer (independent cursor per instance) - Streams raw UYVY422 frames to stdout continuously - SIGPIPE detection via write() return — exits cleanly on ffmpeg exit - SIGTERM/SIGINT clean stop from capture-manager - Periodic stats to stderr (every 300 frames) - Exit codes: 0=clean, 1=slot not found, 2=EPIPE - services/framecache/CMakeLists.txt: add fc_pipe target + install - services/framecache/Dockerfile: copy fc_pipe to runtime image - services/capture/Dockerfile: - New fc-pipe-builder stage (builds fc_pipe from framecache sources) - Copies fc_pipe binary to /usr/local/bin/fc_pipe in runtime image - services/capture/src/capture-manager.js: - _buildInputArgs: new framecache path for deltacast + sdi/blackmagic when FC_SLOT_ID env is set (injected by node-agent from bridge fmt JSON) - Spawns fc_pipe <slot_id> as child process - Uses pipe:0 as ffmpeg rawvideo input 0 - Audio FIFO (unchanged) as ffmpeg input 1 - Falls back to legacy FIFO path when FC_SLOT_ID unset - audioMap: covers blackmagic via framecache (input 1 for audio FIFO) - isInterlacedSource: covers blackmagic interlaced signals - hiresStdio: pipe stdin when bridgeProcess set (fc_pipe stdout→ffmpeg) - Non-growing spawn: pipes fc_pipe.stdout → ffmpeg.stdin - Growing orchestrator spawn: pipes fc_pipe.stdout → bash.stdin - sdiHlsDir: covers blackmagic source type - Session state stores _fcPipeProcess for clean stop - stop(): sends SIGTERM to fc_pipe after ffmpeg SIGINT
2026-06-03 11:32:40 -04:00
}
/* 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 };