From f2542bc929beddaf67298c223716c007bf0d10ef Mon Sep 17 00:00:00 2001 From: Zac Gaetano Date: Fri, 29 May 2026 12:30:01 -0400 Subject: [PATCH] feat(nvenc): GPU sidecar passthrough + All-Intra HEVC capture codec MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Phase 0.2 of the NVENC All-Intra HEVC ingest plan. node-agent/handleSidecarStart: - Accept useGpu: true in the sidecar start body - When useGpu: adds Runtime=nvidia, DeviceRequests=[gpu], and injects NVIDIA_VISIBLE_DEVICES=all + NVIDIA_DRIVER_CAPABILITIES=video,compute,utility into the container env. CPU-codec recorders are unaffected (useGpu defaults false). mam-api/recorders (start endpoint): - Derive useGpu from recorder.recording_codec — true for hevc_nvenc/h264_nvenc - Pass useGpu to remote sidecar start body - Apply same Runtime/DeviceRequests to the local Docker spawn path capture/capture-manager: - Update hevc_nvenc codec entry with all-intra flags: -g 1 -bf 0 (every frame IDR, no B-frames — required for growing-file edit-while-record), -rc vbr, -profile:v main10, pixFmt p010le (10-bit 4:2:0) Next: validation gate (§8) — test MXF OP1a then fragmented MOV on one DeckLink channel, mount in Premiere while recording. Co-Authored-By: Claude Sonnet 4.6 --- services/capture/src/capture-manager.js | 14 ++++++++- services/mam-api/src/routes/recorders.js | 34 ++++++++++++++++----- services/node-agent/index.js | 39 ++++++++++++++++++++---- 3 files changed, 72 insertions(+), 15 deletions(-) diff --git a/services/capture/src/capture-manager.js b/services/capture/src/capture-manager.js index 4e503e7..f1bc2f1 100644 --- a/services/capture/src/capture-manager.js +++ b/services/capture/src/capture-manager.js @@ -28,7 +28,19 @@ const VIDEO_CODECS = { 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' }, - hevc_nvenc: { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' }, + // All-Intra HEVC on NVENC — the growing-file master codec. + // -g 1 -bf 0: every frame is an IDR (all-intra), no B-frames. + // Required for growing-file edit-while-record (partial file must be + // decodable to the last complete frame without a full GOP). + // -rc vbr: variable bitrate; pair with a high target bitrate (100–160 Mbps + // for 1080i) to rival ProRes HQ quality. + // -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 needed, use prores_hq. + hevc_nvenc: { + args: ['-c:v', 'hevc_nvenc', '-preset', 'p4', '-rc', 'vbr', '-g', '1', '-bf', '0', '-profile:v', 'main10'], + bitrateControl: true, + pixFmt: 'p010le', + }, }; const AUDIO_CODECS = { diff --git a/services/mam-api/src/routes/recorders.js b/services/mam-api/src/routes/recorders.js index 1af2f05..cf6bff3 100644 --- a/services/mam-api/src/routes/recorders.js +++ b/services/mam-api/src/routes/recorders.js @@ -427,6 +427,12 @@ router.post('/:id/start', async (req, res, next) => { } } + // GPU-accelerated codecs require the NVIDIA container runtime on the node. + // hevc_nvenc / h264_nvenc are the only two we currently support; extend + // this list if av1_nvenc or others are added later. + const GPU_CODECS = ['hevc_nvenc', 'h264_nvenc']; + const useGpu = GPU_CODECS.includes(recorder.recording_codec); + // Determine whether to spawn locally or via a remote node-agent. const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id); // For remote sidecars, the capture container runs on the worker host network and cannot @@ -444,7 +450,7 @@ router.post('/:id/start', async (req, res, next) => { const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType }), + body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType, useGpu }), signal: AbortSignal.timeout(15000), }); if (!sidecarRes.ok) { @@ -477,16 +483,28 @@ router.post('/:id/start', async (req, res, next) => { } if (growingEnabled) hostBinds.push('/mnt/NVME/MAM/wild-dragon-growing:/growing'); + const localEnv = [...env]; + if (useGpu) { + localEnv.push('NVIDIA_VISIBLE_DEVICES=all'); + localEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility'); + } + + const localHostConfig = { + Privileged: true, + NetworkMode: dockerNetwork, + PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined, + Binds: hostBinds, + ...(useGpu && { + Runtime: 'nvidia', + DeviceRequests: [{ Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }], + }), + }; + const containerConfig = { Image: 'wild-dragon-capture:latest', - Env: env, + Env: localEnv, ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined, - HostConfig: { - Privileged: true, - NetworkMode: dockerNetwork, - PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined, - Binds: hostBinds, - }, + HostConfig: localHostConfig, NetworkingConfig: { EndpointsConfig: { [dockerNetwork]: { Aliases: [alias] }, diff --git a/services/node-agent/index.js b/services/node-agent/index.js index 441d509..f21c896 100644 --- a/services/node-agent/index.js +++ b/services/node-agent/index.js @@ -87,6 +87,12 @@ async function handleSidecarStart(body, res) { env = [], capturePort = 3001, sourceType = 'sdi', + // useGpu: true → attach NVIDIA runtime + NVIDIA_VISIBLE_DEVICES so the + // sidecar can call hevc_nvenc / h264_nvenc inside capture ffmpeg. + // Only set this when the recorder codec is GPU-accelerated; CPU codecs + // (ProRes, DNxHR, libx264) don't need it and it avoids a hard dep on the + // NVIDIA container runtime on nodes that have no GPU. + useGpu = false, } = body; const binds = [`${LIVE_DIR}:/live`]; @@ -100,14 +106,35 @@ async function handleSidecarStart(body, res) { } catch (_) { /* /dev always exists */ } } + // Build the sidecar environment, injecting NVIDIA vars when GPU is requested. + const sidecarEnv = [...env, `PORT=${capturePort}`]; + if (useGpu) { + // NVIDIA_VISIBLE_DEVICES=all exposes every GPU on the host. + // For a single-GPU node (zampp2 / L4) this is equivalent to pinning GPU 0. + // When we later store per-recorder GPU affinity in the DB we can pass a + // specific UUID here instead. + sidecarEnv.push('NVIDIA_VISIBLE_DEVICES=all'); + sidecarEnv.push('NVIDIA_DRIVER_CAPABILITIES=video,compute,utility'); + } + + const hostConfig = { + NetworkMode: 'host', + Privileged: true, + Binds: binds, + }; + if (useGpu) { + // Tell Docker to use the NVIDIA container runtime for this container. + // Equivalent to `docker run --gpus all` / `--runtime=nvidia`. + hostConfig.Runtime = 'nvidia'; + hostConfig.DeviceRequests = [ + { Driver: 'nvidia', Count: -1, Capabilities: [['gpu']] }, + ]; + } + const spec = { Image: image, - Env: [...env, `PORT=${capturePort}`], - HostConfig: { - NetworkMode: 'host', - Privileged: true, - Binds: binds, - }, + Env: sidecarEnv, + HostConfig: hostConfig, }; const createRes = await dockerApi('POST', '/containers/create', spec);