diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 1a14bd1..0a806df 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -380,6 +380,18 @@ const GROWING_EXT = 'mxf'; // which the growing file's recorded duration advances. ~1s at 25/29.97 fps. const GROWING_PART_INTERVAL_FRAMES = 30; +// Growing-file codec selector. Read FRESH from env at record time (standby +// sidecars boot with it unset and receive it per-session via /capture/start). +// 'avci100' -> AVC-Intra 100 (CPU libx264, 4:2:2 10-bit) in MXF OP1a via +// raw2bmx. True-1080p59.94 mastering codec (default). +// 'hevc_nvenc' -> all-intra HEVC (NVENC GPU, 4:2:0 10-bit) in fragmented MOV. +// GPU-offloaded; frees CPU. Lower chroma, .mov not .mxf. +const growingCodec = () => (process.env.GROWING_CODEC === 'hevc_nvenc' ? 'hevc_nvenc' : 'avci100'); +// File extension per growing codec. +const growingExtFor = (codec) => (codec === 'hevc_nvenc' ? 'mov' : 'mxf'); +// Default bitrate for the HEVC-NVENC growing master (all-intra 10-bit is heavy). +const GROWING_HEVC_DEFAULT_BITRATE = '80M'; + // Map the recorder's resolution/fps to (1) the raw2bmx MPEG-2 Long GOP essence // input flag and (2) the ffmpeg edit-rate (`-r`). raw2bmx needs the correct // raster flag so the essence is wrapped as the right XDCAM HD422 variant; an @@ -840,6 +852,57 @@ class CaptureManager { * * Returns the argv for spawn('bash', argv). */ + + /** + * Build the single-ffmpeg argv for a GPU-OFFLOADED growing master: + * all-intra HEVC (NVENC, 10-bit 4:2:0) in a fragmented MOV. + * + * Unlike the AVC-Intra/raw2bmx path, this needs NO FIFO orchestrator and NO + * raw2bmx: ffmpeg writes the growing fragmented-MOV directly to the share. + * +empty_moov writes a valid moov up-front and +frag_keyframe flushes a moof + * fragment per keyframe, so the file is readable (and its duration advances) + * while still growing. force_key_frames expr:1 makes every frame an IDR + * (all-intra) so the growing head is always decodable to the last COMPLETE + * fragment. PROVEN live on zampp3: size + ffprobe duration grow monotonically + * mid-write; finalized file decodes RC=0 (hevc Main10 yuv420p10le 1080p59.94). + * + * GPU pinning via nvencGpuSel() (privileged sidecars see every /dev/nvidiaN, + * so -gpu N is the only reliable NVENC pin). Returns ffmpeg argv (no bash). + */ + _buildGrowingHevcMov({ inputArgs, videoBitrate, framerate, audioChannels, outPath, audioMap = '0:a:0?', hlsDir = null, videoCodec = 'hevc_nvenc', interlaced = false }) { + const vb = videoBitrate || GROWING_HEVC_DEFAULT_BITRATE; + const ach = audioChannels ? Number(audioChannels) : 2; + const args = ['-y', '-hide_banner', '-loglevel', 'warning', '-stats', ...inputArgs]; + + // Deinterlace (SDI) then split: master HEVC + 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) GPU all-intra HEVC 10-bit master -> fragmented MOV at outPath. + args.push('-map', '[vhi]', + '-c:v', 'hevc_nvenc', ...nvencGpuSel(), + '-preset', 'p4', '-rc', 'vbr', '-profile:v', 'main10', '-pix_fmt', 'p010le', + '-bf', '0', '-forced-idr', '1', '-g', '600', '-force_key_frames', 'expr:1', + '-b:v', vb, + '-map', audioMap, '-c:a', 'aac', '-b:a', '256k', '-ar', '48000', '-ac', String(ach), + '-movflags', '+frag_keyframe+empty_moov+default_base_moof', + '-f', 'mov', outPath); + + // (b) optional H.264 HLS preview (unchanged behaviour) -> second output. + 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; + } + _buildGrowingOrchestrator({ inputArgs, videoBitrate, resolution, framerate, audioChannels, outPath, hlsDir, videoCodec, audioMap = '0:a:0?', interlaced = false }) { const { rawFlag, frameRate, ffRate } = deriveGrowingRaster(resolution, framerate, interlaced ? 'i' : 'p'); const vb = videoBitrate || GROWING_DEFAULT_BITRATE; @@ -1034,8 +1097,10 @@ exit "$BMXRC" // format Premiere reads while growing — see GROWING_VIDEO_ELEMENTARY_ARGS / // _buildGrowingOrchestrator), regardless of the recorder's configured // container — so it gets a .mxf extension, not the container's. + const _growCodec = growingActive ? growingCodec() : null; + const _growExt = _growCodec ? growingExtFor(_growCodec) : GROWING_EXT; const growingPath = growingActive - ? `${GROWING_PATH}/${projectId}/${clipName}.${GROWING_EXT}` + ? `${GROWING_PATH}/${projectId}/${clipName}.${_growExt}` : null; // hiresKey MUST match the actual master format/destination: @@ -1044,7 +1109,7 @@ exit "$BMXRC" // (A stale .mov key here would make the proxy job download a nonexistent // object → "unable to open the file on disk".) // - growing fell back to S3 → the normal container extension. - const hiresExt = growingPath ? GROWING_EXT : (CONTAINER_EXT[container] || 'mov'); + const hiresExt = growingPath ? _growExt : (CONTAINER_EXT[container] || 'mov'); const hiresKey = `projects/${projectId}/masters/${clipName}.${hiresExt}`; if (growingPath) { try { mkdirSync(dirname(growingPath), { recursive: true }); } @@ -1165,7 +1230,35 @@ exit "$BMXRC" } let hiresProcess; - if (growingPath) { + if (growingPath && _growCodec === 'hevc_nvenc') { + // ── GPU-OFFLOAD GROWING master: HEVC NVENC -> fragmented MOV ── + // Single ffmpeg, NO raw2bmx / NO FIFO orchestrator. Video from fc_pipe + // stdin (pipe:0) like the non-growing master; frag-MOV grows on disk and + // stays decodable to the last complete fragment. Proven live on zampp3. + const hevcArgs = this._buildGrowingHevcMov({ + inputArgs, videoBitrate, framerate, audioChannels, + outPath: growingPath, audioMap, + hlsDir: (sourceType === 'sdi' || sourceType === 'deltacast') ? sdiHlsDir : null, + videoCodec, interlaced: isInterlacedSource, + }); + console.log('[capture] growing master via HEVC-NVENC frag-MOV; args=' + hevcArgs.length); + hiresProcess = spawn('ffmpeg', hevcArgs, { + 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] hevc growing stdin error: ${e.message}`); + }); + bridgeProcess.stdout.on('error', (e) => { + console.warn(`[capture] fc_pipe stdout error: ${e && e.message}`); + }); + bridgeProcess.stdout.pipe(hiresProcess.stdin); + bridgeProcess.on('exit', () => { + try { if (hiresProcess.stdin) hiresProcess.stdin.end(); } catch (_) {} + }); + } + } else if (growingPath) { // ── GROWING master: raw2bmx orchestrator ────────────────────────── // One ffmpeg (single SDI read) → MPEG-2 422 elementary + PCM to FIFOs + // the H.264 HLS preview; raw2bmx muxes the growing OP1a MXF from the FIFOs. diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 8caa1ea..445fc6d 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -818,6 +818,9 @@ router.post('/:id/start', requireRecorderEdit, async (req, res, next) => { `ASSET_ID=${assetIdLive}`, `MAM_API_TOKEN=${process.env.CAPTURE_TOKEN || ''}`, `GROWING_ENABLED=${growingEnabled ? 'true' : 'false'}`, + // Growing codec: 'avci100' (CPU AVC-Intra 100 MXF, default) or + // 'hevc_nvenc' (GPU all-intra HEVC frag-MOV). capture-manager reads this. + `GROWING_CODEC=${recorder.growing_codec === 'hevc_nvenc' ? 'hevc_nvenc' : 'avci100'}`, `GROWING_PATH=/growing`, // SMB mount details for the in-container CIFS mount (Approach A). Empty // GROWING_SMB_MOUNT → capture falls back to the host-bound /growing volume