2026-05-31 14:50:31 -04:00
import { spawn , execFileSync } from 'child_process' ;
2026-05-31 18:14:59 -04:00
import { mkdirSync , writeFileSync , createReadStream , statSync , unlinkSync } from 'node:fs' ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
import { dirname } from 'node:path' ;
2026-04-07 21:58:29 -04:00
import { v4 as uuidv4 } from 'uuid' ;
import { createUploadStream } from './s3/client.js' ;
const S3 _BUCKET = process . env . S3 _BUCKET || 'wild-dragon' ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
// 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.
2026-05-31 14:50:31 -04:00
// Toggled per-recorder via `GROWING_ENABLED=true` on the capture container
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
// (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' ;
2026-05-31 14:50:31 -04:00
// 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).
2026-05-31 17:56:45 -04:00
// 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 || '' ) ;
2026-05-31 14:50:31 -04:00
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 } \n password= ${ 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 ) ;
}
}
2026-05-21 00:19:00 -04:00
// ── 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 = {
2026-05-21 17:17:31 -04:00
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' } ,
2026-05-29 12:30:01 -04:00
// All-Intra HEVC on NVENC — the growing-file master codec.
2026-05-29 13:23:44 -04:00
// 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).
2026-05-29 12:30:01 -04:00
hevc _nvenc : {
2026-05-29 13:23:44 -04:00
args : [ '-c:v' , 'hevc_nvenc' , '-preset' , 'p4' , '-rc' , 'vbr' , '-bf' , '0' , '-forced-idr' , '1' , '-g' , '600' , '-force_key_frames' , 'expr:1' , '-profile:v' , 'main10' ] ,
2026-05-29 12:30:01 -04:00
bitrateControl : true ,
pixFmt : 'p010le' ,
} ,
2026-05-21 00:19:00 -04:00
} ;
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 ,
2026-05-31 18:14:59 -04:00
growing = false ,
2026-05-21 00:19:00 -04:00
} ) {
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 ) ;
2026-05-21 17:17:31 -04:00
if ( v . pixFmt ) args . push ( '-pix_fmt' , v . pixFmt ) ;
2026-05-21 00:19:00 -04:00
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 ) ) ;
2026-05-31 18:14:59 -04:00
// 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.
2026-05-21 17:17:31 -04:00
if ( fmt === 'mov' || fmt === 'mp4' ) {
2026-05-31 18:14:59 -04:00
args . push ( '-movflags' , growing ? '+frag_keyframe+empty_moov+default_base_moof' : '+faststart' ) ;
2026-05-21 00:19:00 -04:00
}
2026-05-31 18:14:59 -04:00
// ProRes-in-MOV must carry a QuickTime brand or some importers reject the tag.
2026-05-21 00:19:00 -04:00
args . push ( '-f' , fmt ) ;
return args ;
}
2026-04-07 21:58:29 -04:00
class CaptureManager {
constructor ( ) {
this . state = {
recording : false ,
sessionId : null ,
processes : { } ,
currentSession : { } ,
2026-05-17 07:39:19 -04:00
framesReceived : 0 ,
currentFps : 0 ,
lastFrameAt : null ,
lastError : null ,
2026-04-07 21:58:29 -04:00
} ;
}
2026-05-16 08:19:41 -04:00
/ * *
* Build FFmpeg input arguments based on source type .
* Returns { inputArgs , isNetwork }
* @ private
* /
2026-05-21 17:17:31 -04:00
async _buildInputArgs ( { sourceType , device , sourceUrl , listen , listenPort , streamKey } ) {
2026-05-16 08:19:41 -04:00
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' ;
}
}
2026-05-17 07:39:19 -04:00
return { inputArgs : [ '-probesize' , '32M' , '-analyzeduration' , '10M' , '-fflags' , '+genpts' , '-i' , url ] , isNetwork : true } ;
2026-05-16 08:19:41 -04:00
}
if ( sourceType === 'rtmp' ) {
if ( listen ) {
const port = listenPort || 1935 ;
const key = streamKey || 'stream' ;
return {
2026-05-17 07:39:19 -04:00
inputArgs : [ '-probesize' , '32M' , '-analyzeduration' , '10M' , '-fflags' , '+genpts' , '-listen' , '1' , '-i' , ` rtmp://0.0.0.0: ${ port } /live/ ${ key } ` ] ,
2026-05-16 08:19:41 -04:00
isNetwork : true ,
} ;
}
2026-05-17 07:39:19 -04:00
return { inputArgs : [ '-probesize' , '32M' , '-analyzeduration' , '10M' , '-fflags' , '+genpts' , '-i' , sourceUrl ] , isNetwork : true } ;
2026-05-16 08:19:41 -04:00
}
2026-05-28 19:12:40 -04:00
// 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 ,
} ;
}
}
2026-05-16 08:19:41 -04:00
// Default: SDI via DeckLink
2026-05-21 17:17:31 -04:00
// device may be an integer index (0-based) or a full device name string.
2026-05-28 18:25:56 -04:00
// FFmpeg 7.x DeckLink requires the full name (e.g. 'DeckLink Duo 2 (2)').
2026-05-21 17:17:31 -04:00
// Map integer index -> name using ffmpeg -sources decklink at runtime.
2026-05-28 18:25:56 -04:00
//
// 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+.
2026-05-21 17:17:31 -04:00
let deckLinkName = String ( device ) ;
if ( typeof device === 'number' || /^\d+$/ . test ( String ( device ) ) ) {
const idx = parseInt ( device , 10 ) ;
try {
const { execSync } = await import ( 'child_process' ) ;
2026-05-28 18:25:56 -04:00
const out = execSync ( 'ffmpeg -hide_banner -sources decklink 2>&1' , { encoding : 'utf-8' , timeout : 5000 } ) ;
2026-05-21 17:17:31 -04:00
const names = [ ] ;
for ( const line of out . split ( '\n' ) ) {
2026-05-28 18:36:06 -04:00
// 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 ] ) ;
2026-05-21 17:17:31 -04:00
}
2026-05-28 18:25:56 -04:00
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 ) ;
2026-05-21 17:17:31 -04:00
}
}
2026-05-16 08:19:41 -04:00
return {
2026-05-21 17:17:31 -04:00
inputArgs : [ '-f' , 'decklink' , '-i' , deckLinkName ] ,
2026-05-16 08:19:41 -04:00
isNetwork : false ,
} ;
}
2026-04-07 21:58:29 -04:00
/ * *
2026-05-21 00:19:00 -04:00
* 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 .
2026-04-07 21:58:29 -04:00
* /
2026-05-16 08:19:41 -04:00
async start ( {
2026-05-18 07:29:50 -04:00
assetId ,
2026-05-16 08:19:41 -04:00
projectId ,
binId ,
clipName ,
device ,
sourceType = 'sdi' ,
sourceUrl ,
listen = false ,
listenPort ,
streamKey ,
2026-05-21 00:19:00 -04:00
// ── 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' ,
2026-05-16 08:19:41 -04:00
} ) {
2026-05-18 07:29:50 -04:00
this . _assetIdForHls = assetId || null ;
2026-04-07 21:58:29 -04:00
if ( this . state . recording ) {
throw new Error ( 'Capture already in progress' ) ;
}
const sessionId = uuidv4 ( ) ;
2026-05-21 00:19:00 -04:00
const hiresExt = CONTAINER _EXT [ container ] || 'mov' ;
const proxyExt = CONTAINER _EXT [ proxyContainer ] || 'mp4' ;
const hiresKey = ` projects/ ${ projectId } /masters/ ${ clipName } . ${ hiresExt } ` ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
// Growing-files: write master to the local SMB share instead of streaming
// to S3. Path is relative to the container's GROWING_PATH mount.
2026-05-31 14:50:31 -04:00
//
// 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
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
? ` ${ 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 ) ; }
}
2026-05-28 18:36:06 -04:00
// 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 ;
2026-05-16 08:19:41 -04:00
2026-04-07 21:58:29 -04:00
const startedAt = new Date ( ) . toISOString ( ) ;
2026-05-21 17:17:31 -04:00
const { inputArgs , isNetwork } = await this . _buildInputArgs ( {
2026-05-21 00:19:00 -04:00
sourceType , device , sourceUrl , listen , listenPort , streamKey ,
2026-04-07 21:58:29 -04:00
} ) ;
2026-05-21 00:19:00 -04:00
const hiresCodecArgs = buildEncodeArgs ( {
codec : videoCodec , videoBitrate , framerate ,
audioCodec , audioBitrate , audioChannels ,
container ,
isNetwork ,
isProxy : false ,
2026-05-31 18:14:59 -04:00
// 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 ,
2026-05-21 00:19:00 -04:00
} ) ;
console . log ( '[capture] hires ffmpeg args:' , hiresCodecArgs . join ( ' ' ) ) ;
2026-05-16 08:19:41 -04:00
2026-05-21 20:14:02 -04:00
const sdiFilterArgs = ( sourceType === 'sdi' ) ? [ '-vf' , 'yadif=mode=1:deint=1' ] : [ ] ;
2026-05-21 19:57:22 -04:00
2026-05-31 18:14:59 -04:00
// 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' ] ;
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
2026-05-28 23:20:02 -04:00
// 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 } ) ;
2026-04-07 21:58:29 -04:00
2026-05-31 18:14:59 -04:00
// 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.
2026-05-16 08:19:41 -04:00
const processes = { hires : hiresProcess } ;
2026-05-31 18:14:59 -04:00
const uploads = { hires : growingPath ? Promise . resolve ( { growingPath } ) : null } ;
2026-05-21 00:19:00 -04:00
// ── HLS tee for network sources (live preview in the UI) ──────────
2026-05-18 07:29:50 -04:00
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 ) ;
}
}
2026-05-16 08:19:41 -04:00
hiresProcess . stderr . on ( 'data' , ( data ) => {
2026-05-17 07:39:19 -04:00
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 ) ;
}
2026-05-16 08:19:41 -04:00
} ) ;
2026-05-28 18:36:06 -04:00
// 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.
2026-04-07 21:58:29 -04:00
this . state . recording = true ;
this . state . sessionId = sessionId ;
2026-05-16 08:19:41 -04:00
this . state . processes = processes ;
2026-05-17 07:39:19 -04:00
this . state . framesReceived = 0 ;
this . state . currentFps = 0 ;
this . state . lastFrameAt = null ;
this . state . lastError = null ;
2026-04-07 21:58:29 -04:00
this . state . currentSession = {
sessionId ,
projectId ,
binId ,
clipName ,
device ,
2026-05-16 08:19:41 -04:00
sourceType ,
sourceUrl ,
2026-04-07 21:58:29 -04:00
hiresKey ,
proxyKey ,
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
growingPath ,
2026-05-31 18:14:59 -04:00
localMasterPath ,
2026-04-07 21:58:29 -04:00
startedAt ,
duration : 0 ,
2026-05-16 08:19:41 -04:00
uploads ,
2026-05-21 00:19:00 -04:00
codecs : {
videoCodec , videoBitrate , framerate ,
audioCodec , audioBitrate , audioChannels , container ,
proxyEnabled , proxyVideoCodec , proxyVideoBitrate ,
proxyAudioCodec , proxyAudioBitrate , proxyAudioChannels , proxyContainer ,
} ,
2026-04-07 21:58:29 -04:00
} ;
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 ;
2026-05-31 18:14:59 -04:00
// 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 ) ;
} ) ;
2026-05-21 00:19:00 -04:00
if ( processes . hires ) processes . hires . kill ( 'SIGINT' ) ;
2026-05-18 07:29:50 -04:00
if ( processes . proxy ) processes . proxy . kill ( 'SIGINT' ) ;
2026-05-21 00:19:00 -04:00
if ( processes . hls ) { try { processes . hls . kill ( 'SIGINT' ) ; } catch ( _ ) { } }
2026-04-07 21:58:29 -04:00
2026-05-31 18:14:59 -04:00
// Wait for the master writer to finalize before we read/upload the file.
await waitExit ( processes . hires ) ;
2026-05-31 14:50:31 -04:00
// 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 ( ) ;
2026-04-07 21:58:29 -04:00
try {
2026-05-31 18:14:59 -04:00
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 ) ;
}
2026-05-21 00:19:00 -04:00
if ( currentSession . uploads . proxy ) uploadPromises . push ( currentSession . uploads . proxy ) ;
2026-05-16 08:19:41 -04:00
await Promise . all ( uploadPromises ) ;
2026-04-07 21:58:29 -04:00
} 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 = { } ;
2026-05-22 23:52:30 -04:00
// 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 ;
2026-04-07 21:58:29 -04:00
return {
sessionId ,
projectId : currentSession . projectId ,
binId : currentSession . binId ,
clipName : currentSession . clipName ,
2026-05-16 08:19:41 -04:00
sourceType : currentSession . sourceType ,
2026-04-07 21:58:29 -04:00
hiresKey : currentSession . hiresKey ,
2026-05-21 00:19:00 -04:00
proxyKey : currentSession . proxyKey ,
feat: live HLS preview, proxy worker fixes, Settings tabs, growing-files + Premier panel
- worker/proxy: scale-to-even filter, analyzeduration 100M, skip images, hasAudio
- worker/promotion: SMB landing zone -> S3 on idle, queues proxy job, status='ready'
- web-ui screens-ingest: HlsPreview component replaces fake LiveStrip/FauxFrame
- web-ui screens-admin: functional Settings tabs (S3, GPU, Growing, SDI, AMPP)
- mam-api /settings/growing: GET/PUT growing-files config
- mam-api /assets/:id/live-path: SMB UNC/POSIX path for live growing assets
- capture-manager: GROWING_ENABLED -> write hires to /growing instead of S3 stream
- recorders.js: pass GROWING_ENABLED to capture container, bind /growing mount
- docker-compose: mount /mnt/NVME/MAM/wild-dragon-growing on mam-api + worker
- premiere-plugin: Mount Live button, Relink-to-HiRes, live->ready status poll
2026-05-22 19:12:53 -04:00
growingPath : currentSession . growingPath || null ,
2026-04-07 21:58:29 -04:00
startedAt : currentSession . startedAt ,
stoppedAt ,
duration ,
2026-05-22 23:52:30 -04:00
framesReceived ,
empty : framesReceived === 0 ,
2026-04-07 21:58:29 -04:00
} ;
}
getStatus ( ) {
2026-05-21 00:19:00 -04:00
if ( ! this . state . recording ) return { recording : false } ;
2026-04-07 21:58:29 -04:00
const startTime = new Date ( this . state . currentSession . startedAt ) ;
const now = new Date ( ) ;
const duration = Math . round ( ( now - startTime ) / 1000 ) ;
2026-05-17 07:39:19 -04:00
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' ;
}
2026-04-07 21:58:29 -04:00
return {
recording : true ,
sessionId : this . state . sessionId ,
2026-05-16 08:19:41 -04:00
sourceType : this . state . currentSession . sourceType ,
2026-04-07 21:58:29 -04:00
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 ,
2026-05-17 07:39:19 -04:00
signal ,
framesReceived : this . state . framesReceived ,
currentFps : this . state . currentFps ,
lastFrameAt ,
msSinceFrame ,
lastError : this . state . lastError ,
2026-05-21 00:19:00 -04:00
codecs : this . state . currentSession . codecs ,
2026-04-07 21:58:29 -04:00
} ;
}
_formatSessionResponse ( ) {
const { currentSession , sessionId } = this . state ;
return {
sessionId ,
projectId : currentSession . projectId ,
binId : currentSession . binId ,
clipName : currentSession . clipName ,
device : currentSession . device ,
2026-05-16 08:19:41 -04:00
sourceType : currentSession . sourceType ,
2026-04-07 21:58:29 -04:00
hiresKey : currentSession . hiresKey ,
proxyKey : currentSession . proxyKey ,
startedAt : currentSession . startedAt ,
2026-05-21 00:19:00 -04:00
codecs : currentSession . codecs ,
2026-04-07 21:58:29 -04:00
} ;
}
}
export default new CaptureManager ( ) ;
2026-05-21 00:19:00 -04:00
export { VIDEO _CODECS , AUDIO _CODECS , CONTAINER _FMT , CONTAINER _EXT } ;