The hi-res master was streamed to S3 over a non-seekable pipe, which forced a fragmented MOV (+frag_keyframe+empty_moov) with empty stco/stsz sample tables — Premiere reports "file cannot be opened". Now: fragmentation only for the growing SMB file; finalized master writes to a seekable local temp with +faststart, stop() awaits ffmpeg exit to flush the moov, then uploads the finalized file and cleans up. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
718 lines
30 KiB
JavaScript
718 lines
30 KiB
JavaScript
import { spawn, execFileSync } from 'child_process';
|
|
import { mkdirSync, writeFileSync, createReadStream, statSync, unlinkSync } from 'node:fs';
|
|
import { dirname } from 'node:path';
|
|
import { v4 as uuidv4 } from 'uuid';
|
|
import { createUploadStream } from './s3/client.js';
|
|
|
|
const S3_BUCKET = process.env.S3_BUCKET || 'wild-dragon';
|
|
|
|
// Growing-files mode: writes the master to a local SMB-backed share that the
|
|
// editor can mount, instead of streaming to S3 in real time. The promotion
|
|
// worker uploads the finalized file to S3 after the recording stops.
|
|
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
|
|
// (see routes/recorders.js where the env is composed).
|
|
const GROWING_ENABLED = process.env.GROWING_ENABLED === 'true';
|
|
const GROWING_PATH = process.env.GROWING_PATH || '/growing';
|
|
|
|
// Approach A: when a CIFS source is supplied, this (privileged) container mounts
|
|
// the SMB landing-zone share at GROWING_PATH itself, using credentials supplied
|
|
// by the API from global Settings. Empty GROWING_SMB_MOUNT → no system mount
|
|
// (the host-bound /growing volume is used instead, or S3 streaming if growing
|
|
// is off).
|
|
// mount.cifs needs a UNC source (//host/share). Operators (and Settings) often
|
|
// store the share as an `smb://host/share` URL or a Windows `\\host\share`
|
|
// path; the kernel rejects those outright ("Mounting cifs URL not implemented
|
|
// yet"), which silently drops us back to S3. Normalize any of these forms to
|
|
// the `//host/share` UNC the mount helper accepts.
|
|
function toUncShare(raw) {
|
|
if (!raw) return '';
|
|
let s = String(raw).trim().replace(/\\/g, '/'); // \\host\share -> //host/share
|
|
s = s.replace(/^smb:\/\//i, '//'); // smb://host/share -> //host/share
|
|
if (!s.startsWith('//')) s = '//' + s.replace(/^\/+/, ''); // host/share -> //host/share
|
|
return s;
|
|
}
|
|
const GROWING_SMB_MOUNT = toUncShare(process.env.GROWING_SMB_MOUNT || '');
|
|
const GROWING_SMB_USERNAME = process.env.GROWING_SMB_USERNAME || '';
|
|
const GROWING_SMB_PASSWORD = process.env.GROWING_SMB_PASSWORD || '';
|
|
const GROWING_SMB_VERS = process.env.GROWING_SMB_VERS || '3.0';
|
|
const SMB_CREDS_FILE = '/run/smb-creds';
|
|
|
|
// True when GROWING_PATH is already a mountpoint (e.g. a prior session left it
|
|
// mounted, or a host bind-mount is present).
|
|
function isMounted(path) {
|
|
try { execFileSync('mountpoint', ['-q', path]); return true; }
|
|
catch { return false; }
|
|
}
|
|
|
|
// Mount the CIFS growing share at GROWING_PATH. Credentials go in a root-only
|
|
// file (NOT the command line) so they never appear in `ps`/process listings.
|
|
// Returns true on success (or if already mounted), false on failure — callers
|
|
// fall back to S3 streaming so a recording is never lost.
|
|
function mountGrowingShare() {
|
|
if (!GROWING_SMB_MOUNT) return false;
|
|
try {
|
|
if (isMounted(GROWING_PATH)) {
|
|
console.log('[capture] growing share already mounted at', GROWING_PATH);
|
|
return true;
|
|
}
|
|
try { mkdirSync(GROWING_PATH, { recursive: true }); } catch (_) {}
|
|
writeFileSync(
|
|
SMB_CREDS_FILE,
|
|
`username=${GROWING_SMB_USERNAME}\npassword=${GROWING_SMB_PASSWORD}\n`,
|
|
{ mode: 0o600 }
|
|
);
|
|
const opts = [
|
|
`credentials=${SMB_CREDS_FILE}`,
|
|
'uid=0', 'gid=0', 'file_mode=0664', 'dir_mode=0775',
|
|
`vers=${GROWING_SMB_VERS}`,
|
|
].join(',');
|
|
execFileSync('mount', ['-t', 'cifs', GROWING_SMB_MOUNT, GROWING_PATH, '-o', opts],
|
|
{ stdio: ['ignore', 'ignore', 'pipe'] });
|
|
console.log('[capture] mounted CIFS growing share', GROWING_SMB_MOUNT, '->', GROWING_PATH);
|
|
return true;
|
|
} catch (err) {
|
|
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
|
|
console.error('[capture] CIFS mount failed (falling back to S3 streaming):', stderr);
|
|
return false;
|
|
}
|
|
}
|
|
|
|
// Best-effort unmount on session stop. Ignores "not mounted".
|
|
function unmountGrowingShare() {
|
|
if (!GROWING_SMB_MOUNT) return;
|
|
try {
|
|
if (isMounted(GROWING_PATH)) {
|
|
execFileSync('umount', [GROWING_PATH], { stdio: ['ignore', 'ignore', 'pipe'] });
|
|
console.log('[capture] unmounted growing share at', GROWING_PATH);
|
|
}
|
|
} catch (err) {
|
|
const stderr = err.stderr ? err.stderr.toString().trim() : err.message;
|
|
console.warn('[capture] growing share unmount failed (ignored):', stderr);
|
|
}
|
|
}
|
|
|
|
// ── Codec catalogue ──────────────────────────────────────────────────────
|
|
// Each entry maps the UI value to a base ffmpeg flag set. Bitrate / framerate
|
|
// / pix_fmt are layered on top from the per-recorder configuration.
|
|
const VIDEO_CODECS = {
|
|
prores_hq: { args: ['-c:v', 'prores_ks', '-profile:v', '3'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
|
prores_422: { args: ['-c:v', 'prores_ks', '-profile:v', '2'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
|
prores_lt: { args: ['-c:v', 'prores_ks', '-profile:v', '1'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
|
prores_proxy: { args: ['-c:v', 'prores_ks', '-profile:v', '0'], bitrateControl: false, pixFmt: 'yuv422p10le' },
|
|
dnxhd: { args: ['-c:v', 'dnxhd'], bitrateControl: true, pixFmt: 'yuv422p' },
|
|
dnxhr_hq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_hq'], bitrateControl: false, pixFmt: 'yuv422p' },
|
|
dnxhr_sq: { args: ['-c:v', 'dnxhd', '-profile:v', 'dnxhr_sq'], bitrateControl: false, pixFmt: 'yuv422p' },
|
|
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
|
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
|
|
h265: { args: ['-c:v', 'libx265', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
|
|
// All-Intra HEVC on NVENC — the growing-file master codec.
|
|
// Goal: every frame an IDR (all-intra), so a still-growing file is decodable
|
|
// to its last complete frame — the prerequisite for edit-while-record.
|
|
//
|
|
// NVENC will NOT accept `-g 1`: InitializeEncoder enforces
|
|
// "GopLength > numBFrames + 1", so with -bf 0 the minimum GOP is 2 and -g 1
|
|
// is rejected with EINVAL (validated on the L4, driver 595). The working
|
|
// recipe for true all-intra is therefore:
|
|
// -bf 0 no B-frames
|
|
// -g 600 large GOP just to satisfy the init check
|
|
// -forced-idr 1 forced keyframes are emitted as IDR
|
|
// -force_key_frames expr:1 force a keyframe on EVERY frame
|
|
// → ffprobe confirms pict_type = I for all frames.
|
|
//
|
|
// Container: fragmented MOV (+frag_keyframe+empty_moov+default_base_moof),
|
|
// NOT MXF — this ffmpeg's MXF muxer rejects HEVC ("Operation not permitted").
|
|
// The frag-MOV index is not deferred to EOF, so the file stays readable while
|
|
// growing. (See docs/design/2026-05-29-all-intra-hevc-ingest.md §8.)
|
|
//
|
|
// -profile:v main10 / p010le: 10-bit 4:2:0 — the closest NVENC HEVC can get
|
|
// to ProRes 4:2:2 10-bit. If strict 4:2:2 is required, use prores_hq (CPU).
|
|
hevc_nvenc: {
|
|
args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', '-profile:v', 'main10'],
|
|
bitrateControl: true,
|
|
pixFmt: 'p010le',
|
|
},
|
|
};
|
|
|
|
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',
|
|
};
|
|
|
|
function buildEncodeArgs({
|
|
codec, videoBitrate, framerate,
|
|
audioCodec, audioBitrate, audioChannels,
|
|
container, isNetwork, isProxy = false,
|
|
growing = false,
|
|
}) {
|
|
const v = VIDEO_CODECS[codec] || (isProxy ? VIDEO_CODECS.h264 : VIDEO_CODECS.prores_hq);
|
|
const a = AUDIO_CODECS[audioCodec] || (isProxy ? AUDIO_CODECS.aac : AUDIO_CODECS.pcm_s24le);
|
|
const fmt = CONTAINER_FMT[container] || (isProxy ? 'mp4' : 'mov');
|
|
|
|
const args = [];
|
|
if (isNetwork) args.push('-map', '0:v:0?', '-map', '0:a:0?');
|
|
|
|
args.push(...v.args);
|
|
if (v.pixFmt) args.push('-pix_fmt', v.pixFmt);
|
|
if (v.bitrateControl && videoBitrate) args.push('-b:v', videoBitrate);
|
|
if (framerate && framerate !== 'native') args.push('-r', framerate);
|
|
|
|
args.push(...a.args);
|
|
if (a.bitrateControl && audioBitrate) args.push('-b:a', audioBitrate);
|
|
if (audioChannels) args.push('-ac', String(audioChannels));
|
|
|
|
// moov-atom placement is the difference between a Premiere-openable master and
|
|
// a "file cannot be opened" error.
|
|
//
|
|
// - Growing-file masters (edit-while-record on the SMB share) MUST be
|
|
// fragmented so a moov/mvex is present from the first frame and the file is
|
|
// decodable while still being written. The samples live in moof/trun boxes.
|
|
//
|
|
// - Finalized masters (the S3-piped recording that stops cleanly) must NOT be
|
|
// fragmented. Adobe Premiere's QuickTime/MOV importer reads the classic
|
|
// stco/stsz/stts sample tables in a single top-level moov; a fragmented MOV
|
|
// (moof/trun, empty sample tables) makes Premiere report "file cannot be
|
|
// opened." We write a clean, non-fragmented MOV instead.
|
|
// `+faststart` puts the moov before mdat on the second pass so the file is
|
|
// instantly seekable/streamable too.
|
|
if (fmt === 'mov' || fmt === 'mp4') {
|
|
args.push('-movflags', growing ? '+frag_keyframe+empty_moov+default_base_moof' : '+faststart');
|
|
}
|
|
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
|
|
args.push('-f', fmt);
|
|
|
|
return args;
|
|
}
|
|
|
|
class CaptureManager {
|
|
constructor() {
|
|
this.state = {
|
|
recording: false,
|
|
sessionId: null,
|
|
processes: {},
|
|
currentSession: {},
|
|
framesReceived: 0,
|
|
currentFps: 0,
|
|
lastFrameAt: null,
|
|
lastError: null,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Build FFmpeg input arguments based on source type.
|
|
* Returns { inputArgs, isNetwork }
|
|
* @private
|
|
*/
|
|
async _buildInputArgs({ sourceType, device, sourceUrl, listen, listenPort, streamKey }) {
|
|
if (sourceType === 'srt') {
|
|
let url;
|
|
if (listen) {
|
|
const port = listenPort || 9000;
|
|
url = `srt://0.0.0.0:${port}?mode=listener`;
|
|
} else {
|
|
url = sourceUrl;
|
|
if (!url.includes('mode=')) {
|
|
url += (url.includes('?') ? '&' : '?') + 'mode=caller';
|
|
}
|
|
}
|
|
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', url], isNetwork: true };
|
|
}
|
|
|
|
if (sourceType === 'rtmp') {
|
|
if (listen) {
|
|
const port = listenPort || 1935;
|
|
const key = streamKey || 'stream';
|
|
return {
|
|
inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-listen', '1', '-i', `rtmp://0.0.0.0:${port}/live/${key}`],
|
|
isNetwork: true,
|
|
};
|
|
}
|
|
return { inputArgs: ['-probesize','32M','-analyzeduration','10M','-fflags','+genpts','-i', sourceUrl], isNetwork: true };
|
|
}
|
|
|
|
// Deltacast SDI via VideoMaster SDK FFmpeg plugin.
|
|
// FFmpeg input format is 'deltacast', device address is 'deltacast://<index>'.
|
|
// When the physical device is absent (/dev/deltacast<N> missing), fall back
|
|
// to a lavfi test card so development and integration testing work without hardware.
|
|
if (sourceType === 'deltacast') {
|
|
const idx = (typeof device === 'number' || /^\d+$/.test(String(device)))
|
|
? parseInt(device, 10)
|
|
: 0;
|
|
const { existsSync } = await import('node:fs');
|
|
const deviceNode = `/dev/deltacast${idx}`;
|
|
if (existsSync(deviceNode)) {
|
|
console.log(`[capture] Deltacast index ${idx} → ${deviceNode} (hardware)`);
|
|
return {
|
|
inputArgs: ['-f', 'deltacast', '-i', `deltacast://${idx}`],
|
|
isNetwork: false,
|
|
};
|
|
} else {
|
|
// No hardware — lavfi test card with port label + timecode burn-in.
|
|
// Matches the deltacast-sdi-recorder standalone app fallback exactly so
|
|
// recorded files look right in the MAM library during dev.
|
|
console.warn(`[capture] Deltacast device ${deviceNode} not found — using lavfi test card for port ${idx}`);
|
|
const testSrc = [
|
|
`testsrc2=size=1920x1080:rate=30`,
|
|
`drawtext=text='DELTACAST PORT ${idx} — TEST MODE':fontsize=48:fontcolor=white:x=(w-text_w)/2:y=(h-text_h)/2`,
|
|
`drawtext=text='%{localtime\\:%H\\:%M\\:%S}':fontsize=32:fontcolor=yellow:x=10:y=10`,
|
|
].join(',');
|
|
return {
|
|
inputArgs: [
|
|
'-f', 'lavfi', '-i', testSrc,
|
|
'-f', 'lavfi', '-i', 'sine=frequency=1000:sample_rate=48000',
|
|
'-map', '0:v:0', '-map', '1:a:0',
|
|
],
|
|
isNetwork: false,
|
|
};
|
|
}
|
|
}
|
|
|
|
// Default: SDI via DeckLink
|
|
// 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+.
|
|
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,
|
|
};
|
|
}
|
|
|
|
/**
|
|
* Start a new capture session.
|
|
*
|
|
* Codec parameters all have sensible defaults so legacy callers (no codec
|
|
* args) still produce ProRes HQ master + H.264 proxy.
|
|
*/
|
|
async start({
|
|
assetId,
|
|
projectId,
|
|
binId,
|
|
clipName,
|
|
device,
|
|
sourceType = 'sdi',
|
|
sourceUrl,
|
|
listen = false,
|
|
listenPort,
|
|
streamKey,
|
|
// ── Recording codec ─────────────────────────────────────────────
|
|
videoCodec = 'prores_hq',
|
|
videoBitrate = null,
|
|
framerate = null,
|
|
audioCodec = 'pcm_s24le',
|
|
audioBitrate = null,
|
|
audioChannels = 2,
|
|
container = 'mov',
|
|
// ── Proxy codec ─────────────────────────────────────────────────
|
|
proxyEnabled = true,
|
|
proxyVideoCodec = 'h264',
|
|
proxyVideoBitrate = '8M',
|
|
proxyFramerate = null,
|
|
proxyAudioCodec = 'aac',
|
|
proxyAudioBitrate = '192k',
|
|
proxyAudioChannels = 2,
|
|
proxyContainer = 'mp4',
|
|
}) {
|
|
this._assetIdForHls = assetId || null;
|
|
if (this.state.recording) {
|
|
throw new Error('Capture already in progress');
|
|
}
|
|
|
|
const sessionId = uuidv4();
|
|
const hiresExt = CONTAINER_EXT[container] || 'mov';
|
|
const proxyExt = CONTAINER_EXT[proxyContainer] || 'mp4';
|
|
const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`;
|
|
|
|
// Growing-files: write master to the local SMB share instead of streaming
|
|
// to S3. Path is relative to the container's GROWING_PATH mount.
|
|
//
|
|
// Approach A: if a CIFS source is configured, mount it now. A mount failure
|
|
// is non-fatal — we fall back to S3 streaming so the recording is never
|
|
// lost.
|
|
let growingActive = GROWING_ENABLED;
|
|
if (growingActive && GROWING_SMB_MOUNT) {
|
|
if (!mountGrowingShare()) growingActive = false; // fall back to S3
|
|
}
|
|
const growingPath = growingActive
|
|
? `${GROWING_PATH}/${projectId}/${clipName}.${hiresExt}`
|
|
: null;
|
|
if (growingPath) {
|
|
try { mkdirSync(dirname(growingPath), { recursive: true }); }
|
|
catch (err) { console.error('[capture] could not create growing dir:', err.message); }
|
|
}
|
|
|
|
// DeckLink hardware does NOT support concurrent capture from the same port.
|
|
// Opening a second ffmpeg process on the same DeckLink input while the first
|
|
// is already capturing causes "Cannot Autodetect input stream or No signal"
|
|
// on the second process — making the proxy empty and potentially crashing the
|
|
// container before the hires upload completes.
|
|
//
|
|
// Treat SDI the same as SRT/RTMP: set proxyKey=null here and let the BullMQ
|
|
// worker generate the proxy from the hires master after the recording stops.
|
|
// The stop handler sets needsProxy=true so the worker picks it up.
|
|
const proxyKey = null;
|
|
|
|
const startedAt = new Date().toISOString();
|
|
|
|
const { inputArgs, isNetwork } = await this._buildInputArgs({
|
|
sourceType, device, sourceUrl, listen, listenPort, streamKey,
|
|
});
|
|
|
|
const hiresCodecArgs = buildEncodeArgs({
|
|
codec: videoCodec, videoBitrate, framerate,
|
|
audioCodec, audioBitrate, audioChannels,
|
|
container,
|
|
isNetwork,
|
|
isProxy: false,
|
|
// Only the growing-file master (written to the SMB share for
|
|
// edit-while-record) needs a fragmented MOV. The finalized, S3-piped
|
|
// master must be a clean non-fragmented MOV so Premiere can open it.
|
|
growing: !!growingPath,
|
|
});
|
|
|
|
console.log('[capture] hires ffmpeg args:', hiresCodecArgs.join(' '));
|
|
|
|
const sdiFilterArgs = (sourceType === 'sdi') ? ['-vf', 'yadif=mode=1:deint=1'] : [];
|
|
|
|
// Master output destination.
|
|
//
|
|
// - Growing-files on → write directly to the SMB share (fragmented MOV) so
|
|
// Premiere can mount and edit the live file; promotion worker uploads on EOF.
|
|
//
|
|
// - Growing-files off → write to a LOCAL SEEKABLE temp file, then upload to
|
|
// S3 on stop. We must NOT pipe the MOV muxer to S3 directly: the MOV/MP4
|
|
// muxer cannot write to a non-seekable pipe without `empty_moov`, and an
|
|
// empty_moov/fragmented MOV is exactly what makes Adobe Premiere report
|
|
// "file cannot be opened" (no classic stco/stsz sample tables — samples
|
|
// live in moof/trun). A seekable file lets ffmpeg write a single
|
|
// contiguous moov with full sample tables and `+faststart` moves it to the
|
|
// front, producing a master Premiere opens natively.
|
|
const localMasterPath = growingPath
|
|
? null
|
|
: `/tmp/capture/${sessionId}.${hiresExt}`;
|
|
if (localMasterPath) {
|
|
try { mkdirSync(dirname(localMasterPath), { recursive: true }); }
|
|
catch (err) { console.error('[capture] could not create temp master dir:', err.message); }
|
|
}
|
|
const hiresOutput = growingPath ? growingPath : localMasterPath;
|
|
// ffmpeg now writes a file (not stdout) in both modes → stdout is unused.
|
|
const hiresStdio = ['ignore', 'ignore', 'pipe'];
|
|
|
|
// For SDI we cannot open the DeckLink device a second time for a preview
|
|
// tee, so the live HLS preview is produced as a SECOND OUTPUT of the hires
|
|
// ffmpeg: one decklink read -> yadif -> split -> [ProRes/S3] + [H.264/HLS].
|
|
let sdiHlsDir = null;
|
|
let hiresArgs;
|
|
if (sourceType === 'sdi' && this._assetIdForHls) {
|
|
const fsMod = await import('node:fs');
|
|
sdiHlsDir = '/live/' + this._assetIdForHls;
|
|
try { fsMod.mkdirSync(sdiHlsDir, { recursive: true }); } catch (_) {}
|
|
hiresArgs = [
|
|
...inputArgs,
|
|
'-filter_complex', '[0:v]yadif=mode=1:deint=1,split=2[vhi][vlo]',
|
|
// Output 0 — ProRes master (S3 pipe or growing file)
|
|
'-map', '[vhi]', '-map', '0:a:0?',
|
|
...hiresCodecArgs,
|
|
hiresOutput,
|
|
// Output 1 — low-latency H.264 HLS preview for the UI monitor
|
|
'-map', '[vlo]', '-map', '0:a:0?',
|
|
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
|
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
|
|
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
|
|
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
|
|
'-hls_flags', 'delete_segments+append_list+omit_endlist',
|
|
'-hls_segment_filename', sdiHlsDir + '/seg-%05d.ts',
|
|
sdiHlsDir + '/index.m3u8',
|
|
];
|
|
console.log('[HLS] SDI preview as 2nd output -> ' + sdiHlsDir);
|
|
} else {
|
|
hiresArgs = [ ...inputArgs, ...sdiFilterArgs, ...hiresCodecArgs, hiresOutput ];
|
|
}
|
|
|
|
const hiresProcess = spawn('ffmpeg', hiresArgs, { stdio: hiresStdio });
|
|
|
|
// Growing-files: nothing to upload here (promotion worker handles S3).
|
|
// Non-growing: the master is uploaded from the finalized local file in
|
|
// stop(), once ffmpeg has written the moov and exited cleanly — we can't
|
|
// upload while recording because the file isn't a valid MOV until finalize.
|
|
const processes = { hires: hiresProcess };
|
|
const uploads = { hires: growingPath ? Promise.resolve({ growingPath }) : null };
|
|
|
|
// ── HLS tee for network sources (live preview in the UI) ──────────
|
|
let hlsProcess = null;
|
|
let hlsDir = null;
|
|
if (isNetwork && this._assetIdForHls) {
|
|
try {
|
|
const fs = await import('node:fs');
|
|
hlsDir = '/live/' + this._assetIdForHls;
|
|
fs.mkdirSync(hlsDir, { recursive: true });
|
|
const hlsArgs = [
|
|
...inputArgs,
|
|
'-map', '0:v:0?', '-map', '0:a:0?',
|
|
'-c:v', 'libx264', '-preset', 'veryfast', '-tune', 'zerolatency',
|
|
'-pix_fmt', 'yuv420p', '-b:v', '2M', '-g', '60', '-sc_threshold', '0',
|
|
'-c:a', 'aac', '-b:a', '128k', '-ar', '44100',
|
|
'-f', 'hls', '-hls_time', '2', '-hls_list_size', '15',
|
|
'-hls_flags', 'delete_segments+append_list+omit_endlist',
|
|
'-hls_segment_filename', hlsDir + '/seg-%05d.ts',
|
|
hlsDir + '/index.m3u8',
|
|
];
|
|
hlsProcess = spawn('ffmpeg', hlsArgs, { stdio: ['ignore', 'pipe', 'pipe'] });
|
|
hlsProcess.stderr.on('data', (d) => { console.error('[HLS] ' + d); });
|
|
hlsProcess.on('exit', (c) => console.log('[HLS] exited ' + c));
|
|
processes.hls = hlsProcess;
|
|
console.log('[HLS] tee started -> ' + hlsDir);
|
|
} catch (err) {
|
|
console.error('[HLS] tee failed:', err.message);
|
|
}
|
|
}
|
|
|
|
hiresProcess.stderr.on('data', (data) => {
|
|
const text = data.toString();
|
|
console.error(`[HIRES] ${text}`);
|
|
const m = text.match(/frame=\s*(\d+)\s+fps=\s*([\d.]+)/);
|
|
if (m) {
|
|
this.state.framesReceived = parseInt(m[1], 10);
|
|
this.state.currentFps = parseFloat(m[2]);
|
|
this.state.lastFrameAt = new Date().toISOString();
|
|
}
|
|
if (/Connection refused|No route to host|Connection failed|Input\/output error|Server returned|404 Not Found|Connection timed out/i.test(text)) {
|
|
this.state.lastError = text.trim().slice(0, 240);
|
|
}
|
|
});
|
|
|
|
// Proxy is generated after stop by the BullMQ worker (same as SRT/RTMP).
|
|
// DeckLink hardware does not support two concurrent readers on the same port.
|
|
|
|
this.state.recording = true;
|
|
this.state.sessionId = sessionId;
|
|
this.state.processes = processes;
|
|
this.state.framesReceived = 0;
|
|
this.state.currentFps = 0;
|
|
this.state.lastFrameAt = null;
|
|
this.state.lastError = null;
|
|
this.state.currentSession = {
|
|
sessionId,
|
|
projectId,
|
|
binId,
|
|
clipName,
|
|
device,
|
|
sourceType,
|
|
sourceUrl,
|
|
hiresKey,
|
|
proxyKey,
|
|
growingPath,
|
|
localMasterPath,
|
|
startedAt,
|
|
duration: 0,
|
|
uploads,
|
|
codecs: {
|
|
videoCodec, videoBitrate, framerate,
|
|
audioCodec, audioBitrate, audioChannels, container,
|
|
proxyEnabled, proxyVideoCodec, proxyVideoBitrate,
|
|
proxyAudioCodec, proxyAudioBitrate, proxyAudioChannels, proxyContainer,
|
|
},
|
|
};
|
|
|
|
return this._formatSessionResponse();
|
|
}
|
|
|
|
async stop(sessionId) {
|
|
if (!this.state.recording || this.state.sessionId !== sessionId) {
|
|
throw new Error('No active capture session or session ID mismatch');
|
|
}
|
|
|
|
const { processes, currentSession } = this.state;
|
|
|
|
// Send SIGINT and WAIT for ffmpeg to exit. This is what flushes the MOV
|
|
// trailer (writes the moov atom with the full sample tables). If we uploaded
|
|
// before ffmpeg finalized, the object would have no moov → "moov atom not
|
|
// found" / "file cannot be opened" in Premiere.
|
|
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 ffmpeg refuses to exit.
|
|
setTimeout(() => { try { proc.kill('SIGKILL'); } catch (_) {} finish(); }, 15000);
|
|
});
|
|
|
|
if (processes.hires) processes.hires.kill('SIGINT');
|
|
if (processes.proxy) processes.proxy.kill('SIGINT');
|
|
if (processes.hls) { try { processes.hls.kill('SIGINT'); } catch (_) {} }
|
|
|
|
// Wait for the master writer to finalize before we read/upload the file.
|
|
await waitExit(processes.hires);
|
|
|
|
// Release the CIFS mount (best-effort) once the ffmpeg writers are done with
|
|
// it. The promotion worker reads the staged file from the host/S3 side, not
|
|
// through this container's mount, so unmounting here is safe.
|
|
unmountGrowingShare();
|
|
|
|
try {
|
|
const uploadPromises = [];
|
|
|
|
// Non-growing: upload the finalized local master file to S3 now that the
|
|
// moov has been written. Growing: the promotion worker handles S3.
|
|
if (currentSession.localMasterPath) {
|
|
let size = 0;
|
|
try { size = statSync(currentSession.localMasterPath).size; } catch (_) {}
|
|
if (size > 0) {
|
|
uploadPromises.push(
|
|
createUploadStream(
|
|
S3_BUCKET,
|
|
currentSession.hiresKey,
|
|
createReadStream(currentSession.localMasterPath),
|
|
).then(() => {
|
|
try { unlinkSync(currentSession.localMasterPath); } catch (_) {}
|
|
})
|
|
);
|
|
} else {
|
|
console.warn('[capture] local master is 0 bytes — skipping upload:', currentSession.localMasterPath);
|
|
}
|
|
} else if (currentSession.uploads.hires) {
|
|
uploadPromises.push(currentSession.uploads.hires);
|
|
}
|
|
|
|
if (currentSession.uploads.proxy) uploadPromises.push(currentSession.uploads.proxy);
|
|
await Promise.all(uploadPromises);
|
|
} catch (error) {
|
|
console.error('Error during upload completion:', error);
|
|
}
|
|
|
|
const stoppedAt = new Date().toISOString();
|
|
const startTime = new Date(currentSession.startedAt);
|
|
const stopTime = new Date(stoppedAt);
|
|
const duration = Math.round((stopTime - startTime) / 1000);
|
|
|
|
this.state.recording = false;
|
|
this.state.sessionId = null;
|
|
this.state.processes = {};
|
|
|
|
// No frames received → the upload (if any) produced a 0-byte object.
|
|
// Surface that so the shutdown handler can mark the asset as 'error'
|
|
// instead of posting a broken hi-res key downstream.
|
|
const framesReceived = this.state.framesReceived;
|
|
|
|
return {
|
|
sessionId,
|
|
projectId: currentSession.projectId,
|
|
binId: currentSession.binId,
|
|
clipName: currentSession.clipName,
|
|
sourceType: currentSession.sourceType,
|
|
hiresKey: currentSession.hiresKey,
|
|
proxyKey: currentSession.proxyKey,
|
|
growingPath: currentSession.growingPath || null,
|
|
startedAt: currentSession.startedAt,
|
|
stoppedAt,
|
|
duration,
|
|
framesReceived,
|
|
empty: framesReceived === 0,
|
|
};
|
|
}
|
|
|
|
getStatus() {
|
|
if (!this.state.recording) return { recording: false };
|
|
|
|
const startTime = new Date(this.state.currentSession.startedAt);
|
|
const now = new Date();
|
|
const duration = Math.round((now - startTime) / 1000);
|
|
|
|
const lastFrameAt = this.state.lastFrameAt;
|
|
const msSinceFrame = lastFrameAt ? (Date.now() - new Date(lastFrameAt).getTime()) : null;
|
|
let signal = 'connecting';
|
|
if (this.state.framesReceived > 0) {
|
|
signal = (msSinceFrame !== null && msSinceFrame < 5000) ? 'receiving' : 'lost';
|
|
} else if (this.state.lastError) {
|
|
signal = 'error';
|
|
}
|
|
return {
|
|
recording: true,
|
|
sessionId: this.state.sessionId,
|
|
sourceType: this.state.currentSession.sourceType,
|
|
device: this.state.currentSession.device,
|
|
clipName: this.state.currentSession.clipName,
|
|
projectId: this.state.currentSession.projectId,
|
|
binId: this.state.currentSession.binId,
|
|
duration,
|
|
startedAt: this.state.currentSession.startedAt,
|
|
signal,
|
|
framesReceived: this.state.framesReceived,
|
|
currentFps: this.state.currentFps,
|
|
lastFrameAt,
|
|
msSinceFrame,
|
|
lastError: this.state.lastError,
|
|
codecs: this.state.currentSession.codecs,
|
|
};
|
|
}
|
|
|
|
_formatSessionResponse() {
|
|
const { currentSession, sessionId } = this.state;
|
|
return {
|
|
sessionId,
|
|
projectId: currentSession.projectId,
|
|
binId: currentSession.binId,
|
|
clipName: currentSession.clipName,
|
|
device: currentSession.device,
|
|
sourceType: currentSession.sourceType,
|
|
hiresKey: currentSession.hiresKey,
|
|
proxyKey: currentSession.proxyKey,
|
|
startedAt: currentSession.startedAt,
|
|
codecs: currentSession.codecs,
|
|
};
|
|
}
|
|
}
|
|
|
|
export default new CaptureManager();
|
|
export { VIDEO_CODECS, AUDIO_CODECS, CONTAINER_FMT, CONTAINER_EXT };
|