feat(nvenc): GPU sidecar passthrough + All-Intra HEVC capture codec

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 <noreply@anthropic.com>
This commit is contained in:
Zac Gaetano 2026-05-29 12:30:01 -04:00
parent 0f6c715a30
commit f2542bc929
3 changed files with 72 additions and 15 deletions

View file

@ -28,7 +28,19 @@ const VIDEO_CODECS = {
h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' }, h264: { args: ['-c:v', 'libx264', '-preset', 'medium'], bitrateControl: true, pixFmt: 'yuv420p' },
h264_nvenc: { args: ['-c:v', 'h264_nvenc', '-preset', 'p5'], 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' }, 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 (100160 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 = { const AUDIO_CODECS = {

View file

@ -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. // Determine whether to spawn locally or via a remote node-agent.
const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id); const { remote: isRemote, apiUrl: targetNodeApiUrl } = await resolveNodeTarget(recorder.node_id);
// For remote sidecars, the capture container runs on the worker host network and cannot // 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`, { const sidecarRes = await fetch(`${targetNodeApiUrl}/sidecar/start`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, 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), signal: AbortSignal.timeout(15000),
}); });
if (!sidecarRes.ok) { 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'); 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 = { const containerConfig = {
Image: 'wild-dragon-capture:latest', Image: 'wild-dragon-capture:latest',
Env: env, Env: localEnv,
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined, ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
HostConfig: { HostConfig: localHostConfig,
Privileged: true,
NetworkMode: dockerNetwork,
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
Binds: hostBinds,
},
NetworkingConfig: { NetworkingConfig: {
EndpointsConfig: { EndpointsConfig: {
[dockerNetwork]: { Aliases: [alias] }, [dockerNetwork]: { Aliases: [alias] },

View file

@ -87,6 +87,12 @@ async function handleSidecarStart(body, res) {
env = [], env = [],
capturePort = 3001, capturePort = 3001,
sourceType = 'sdi', 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; } = body;
const binds = [`${LIVE_DIR}:/live`]; const binds = [`${LIVE_DIR}:/live`];
@ -100,14 +106,35 @@ async function handleSidecarStart(body, res) {
} catch (_) { /* /dev always exists */ } } 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 = { const spec = {
Image: image, Image: image,
Env: [...env, `PORT=${capturePort}`], Env: sidecarEnv,
HostConfig: { HostConfig: hostConfig,
NetworkMode: 'host',
Privileged: true,
Binds: binds,
},
}; };
const createRes = await dockerApi('POST', '/containers/create', spec); const createRes = await dockerApi('POST', '/containers/create', spec);