Compare commits
No commits in common. "0ee0cb91efe334586a354419be855e65afb746a0" and "0f6c715a30044951dabcb1056b457f5be87e4496" have entirely different histories.
0ee0cb91ef
...
0f6c715a30
4 changed files with 17 additions and 117 deletions
|
|
@ -1,10 +1,4 @@
|
||||||
# ── Stage 1: Build FFmpeg with DeckLink + NVENC (HEVC/H264) support ─────────
|
# ── Stage 1: Build FFmpeg with DeckLink support ─────────────────────────────
|
||||||
# All-Intra HEVC NVENC is the master codec for growing-file ingest (see
|
|
||||||
# docs/design/2026-05-29-all-intra-hevc-ingest.md). This stage gets the
|
|
||||||
# nv-codec-headers (header-only, no driver / no full CUDA toolkit needed)
|
|
||||||
# so ffmpeg's configure can light up hevc_nvenc / h264_nvenc / cuvid.
|
|
||||||
# At runtime, /dev/nvidia* + the host driver libs (via the NVIDIA Container
|
|
||||||
# Toolkit) supply the actual encoder.
|
|
||||||
FROM debian:bookworm AS ffmpeg-builder
|
FROM debian:bookworm AS ffmpeg-builder
|
||||||
|
|
||||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||||
|
|
@ -19,11 +13,6 @@ COPY sdk/ /decklink-sdk/
|
||||||
COPY patch_decklink.py /patch_decklink.py
|
COPY patch_decklink.py /patch_decklink.py
|
||||||
COPY decklink-sdk16.patch /decklink-sdk16.patch
|
COPY decklink-sdk16.patch /decklink-sdk16.patch
|
||||||
|
|
||||||
# nv-codec-headers — just the ffnvcodec public headers + a pkg-config file.
|
|
||||||
# Pin to a tag known to work with FFmpeg 7.1 (n12.x series).
|
|
||||||
RUN git clone --depth=1 --branch n12.1.14.0 https://github.com/FFmpeg/nv-codec-headers.git /nv-codec-headers \
|
|
||||||
&& make -C /nv-codec-headers PREFIX=/usr/local install
|
|
||||||
|
|
||||||
# Pull FFmpeg 7.1 source
|
# Pull FFmpeg 7.1 source
|
||||||
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
|
RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /ffmpeg
|
||||||
|
|
||||||
|
|
@ -31,15 +20,8 @@ RUN git clone --depth=1 --branch release/7.1 https://git.ffmpeg.org/ffmpeg.git /
|
||||||
RUN python3 /patch_decklink.py
|
RUN python3 /patch_decklink.py
|
||||||
|
|
||||||
WORKDIR /ffmpeg
|
WORKDIR /ffmpeg
|
||||||
# NVENC adds: --enable-nvenc (encoder), --enable-cuvid (decoder), --enable-ffnvcodec.
|
|
||||||
# We deliberately do NOT enable --enable-cuda-nvcc / --enable-libnpp here — those
|
|
||||||
# require the full ~3GB CUDA toolkit and are only needed for GPU filters like
|
|
||||||
# yadif_cuda / scale_cuda. If §5's GPU deinterlace stretch goal goes ahead,
|
|
||||||
# rebuild this image off nvidia/cuda:12.x-devel and flip those flags on.
|
|
||||||
RUN ./configure \
|
RUN ./configure \
|
||||||
--prefix=/usr/local \
|
--prefix=/usr/local \
|
||||||
--extra-cflags="-I/decklink-sdk -I/usr/local/include" \
|
|
||||||
--extra-ldflags="-L/usr/local/lib" \
|
|
||||||
--enable-gpl \
|
--enable-gpl \
|
||||||
--enable-nonfree \
|
--enable-nonfree \
|
||||||
--enable-libx264 \
|
--enable-libx264 \
|
||||||
|
|
@ -50,20 +32,13 @@ RUN ./configure \
|
||||||
--enable-libsrt \
|
--enable-libsrt \
|
||||||
--enable-libzmq \
|
--enable-libzmq \
|
||||||
--enable-decklink \
|
--enable-decklink \
|
||||||
--enable-ffnvcodec \
|
--extra-cflags="-I/decklink-sdk" \
|
||||||
--enable-nvenc \
|
|
||||||
--enable-cuvid \
|
|
||||||
--disable-doc \
|
--disable-doc \
|
||||||
--disable-debug \
|
--disable-debug \
|
||||||
--disable-ffplay \
|
--disable-ffplay \
|
||||||
&& make -j$(nproc) \
|
&& make -j$(nproc) \
|
||||||
&& make install
|
&& make install
|
||||||
|
|
||||||
# Sanity-check: hevc_nvenc and h264_nvenc must be present in the encoder list,
|
|
||||||
# otherwise the resulting image is useless for the All-Intra HEVC pipeline.
|
|
||||||
RUN /usr/local/bin/ffmpeg -hide_banner -encoders 2>&1 | grep -E 'nvenc' \
|
|
||||||
|| (echo 'FATAL: nvenc encoders missing from ffmpeg build' && exit 1)
|
|
||||||
|
|
||||||
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
|
# ── Stage 2: Runtime image ───────────────────────────────────────────────────
|
||||||
FROM node:20-bookworm
|
FROM node:20-bookworm
|
||||||
|
|
||||||
|
|
@ -83,11 +58,6 @@ COPY lib/libDeckLinkAPI.so /usr/lib/libDeckLinkAPI.so
|
||||||
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
|
COPY lib/libDeckLinkPreviewAPI.so /usr/lib/libDeckLinkPreviewAPI.so
|
||||||
RUN ldconfig
|
RUN ldconfig
|
||||||
|
|
||||||
# Mount points the recorder lifecycle expects to exist.
|
|
||||||
# /live — HLS preview output (bound from host LIVE_DIR by node-agent)
|
|
||||||
# /growing — growing-file master output (bound from host /mnt/NVME/MAM/growing)
|
|
||||||
RUN mkdir -p /live /growing
|
|
||||||
|
|
||||||
WORKDIR /app
|
WORKDIR /app
|
||||||
COPY package*.json ./
|
COPY package*.json ./
|
||||||
RUN npm install --omit=dev
|
RUN npm install --omit=dev
|
||||||
|
|
|
||||||
|
|
@ -28,32 +28,7 @@ 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' },
|
||||||
// All-Intra HEVC on NVENC — the growing-file master codec.
|
hevc_nvenc: { args: ['-c:v', 'hevc_nvenc', '-preset', 'p5'], bitrateControl: true, pixFmt: 'yuv420p' },
|
||||||
// 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 = {
|
const AUDIO_CODECS = {
|
||||||
|
|
|
||||||
|
|
@ -427,12 +427,6 @@ 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
|
||||||
|
|
@ -450,7 +444,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, useGpu }),
|
body: JSON.stringify({ image: 'wild-dragon-capture:latest', env, capturePort, sourceType }),
|
||||||
signal: AbortSignal.timeout(15000),
|
signal: AbortSignal.timeout(15000),
|
||||||
});
|
});
|
||||||
if (!sidecarRes.ok) {
|
if (!sidecarRes.ok) {
|
||||||
|
|
@ -483,28 +477,16 @@ 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: localEnv,
|
Env: env,
|
||||||
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
ExposedPorts: Object.keys(exposedPorts).length > 0 ? exposedPorts : undefined,
|
||||||
HostConfig: localHostConfig,
|
HostConfig: {
|
||||||
|
Privileged: true,
|
||||||
|
NetworkMode: dockerNetwork,
|
||||||
|
PortBindings: Object.keys(portBindings).length > 0 ? portBindings : undefined,
|
||||||
|
Binds: hostBinds,
|
||||||
|
},
|
||||||
NetworkingConfig: {
|
NetworkingConfig: {
|
||||||
EndpointsConfig: {
|
EndpointsConfig: {
|
||||||
[dockerNetwork]: { Aliases: [alias] },
|
[dockerNetwork]: { Aliases: [alias] },
|
||||||
|
|
|
||||||
|
|
@ -87,12 +87,6 @@ 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`];
|
||||||
|
|
@ -106,35 +100,14 @@ 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: sidecarEnv,
|
Env: [...env, `PORT=${capturePort}`],
|
||||||
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);
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue