platform: enable existing NVENC H.264 export on Linux

Runtime-load the NVIDIA encode and CUDA driver libraries, retaining the primary context through session destruction. Preserve the Windows Direct3D path and default-off selection, and test driver rejection and deterministic software fallback.

Co-Authored-By: OpenAI Codex <noreply@openai.com>
This commit is contained in:
storybored379 2026-10-08 20:40:01 -07:00
parent 5231852443
commit ea79751a5c
13 changed files with 308 additions and 81 deletions

1
Cargo.lock generated
View File

@ -2107,6 +2107,7 @@ dependencies = [
"filmcraft-render",
"filmcraft-testkit",
"filmcraft-time",
"libloading",
"log",
"objc2-core-foundation",
"objc2-core-media",

View File

@ -49,6 +49,9 @@ windows = { version = "0.62", features = [
"Win32_System_LibraryLoader",
] }
[target.'cfg(all(target_os = "linux", target_pointer_width = "64"))'.dependencies]
libloading = "0.8"
[dev-dependencies]
filmcraft-testkit = { workspace = true }
filmcraft-media = { workspace = true }

View File

@ -61,9 +61,11 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
- After a `flush` (which drains the MFT) the MFT only restarts at an IDR picture; the GOP cache
always seeks after a flush, and a caller that continues from the middle of a GOP gets an error
that `HybridDecoder` answers by replaying the run in software.
- **Windows: NVIDIA NVENC H.264 encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's
`nvEncodeAPI64.dll` (API 12.1, no CUDA or SDK) is loaded at run time, so machines without NVIDIA
still start. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12
- **Windows and 64-bit Linux: NVIDIA NVENC H.264 encoding** (`nvenc/`), 8-bit SDR 4:2:0 for Export. The driver's
`nvEncodeAPI64.dll` (Windows) or `libnvidia-encode.so.1` (Linux), API 12.1, is loaded at run time,
so machines without NVIDIA still start. Windows uses a Direct3D 11 device; Linux retains a primary
CUDA context through the runtime-loaded `libcuda.so.1`, released after the encoder is destroyed.
No SDK, CUDA toolkit or NVIDIA binaries are bundled or required at build time. RGBA is converted with the software encoder's own BT.709 limited conversion into NV12
input buffers (a ring of eight); the encoder runs preset P5 with high-quality tuning, CABAC (CAVLC
for Baseline), one B-frame when the profile and GPU allow it, and an IDR at every keyframe
distance; the parameter sets go into `avcC`. Export ▸ Hardware encoding (off by default) selects it.
@ -159,9 +161,9 @@ level and flags are the encoder's own. If it is missing the export stops with th
| `tests/fallback.rs` (every OS) | `HybridDecoder` with a stand-in hardware decoder failing after N samples (every sync sample ± a few, first / last sample, after a seek): output identical to the software decoder; in-band parameter sets identical to the sample entry's stay in hardware, different ones switch to software |
| `tests/setting.rs` | Hardware decoding Off gives the software decoder through `make_video_decoder` and the media stack (no hardware frames); Auto gives VideoToolbox where available |
| `tests/hardware_encode.rs` (macOS) | what the hardware path takes and declines; round trip through our software decoder (every picture, in order, luma PSNR above 30 dB, keyframes no further apart than asked, no composition offsets); an export through `filmcraft_export` that decodes in our decoder and in ffmpeg / ffprobe (profile, size, frame count, BT.709); the built-in encoder still exporting everything hardware declines; exact output size at sizes that are not multiples of 16; hostile configurations (zero, huge, odd sizes, frame rates, bitrates, keyframe intervals, wrong planes) give errors and never panic; encoders dropped at any point do not crash or hang. The same for **HEVC**: Main profile, 8-bit 4:2:0, `hvc1` entry with VPS / SPS / PPS and 4-byte lengths, MP4 and QuickTime, AAC audio, two-pass refused, the format list agreeing with the probe, ffprobe reading `codec_name=hevc`, `profile=Main`, `codec_tag_string=hvc1`, `pix_fmt=yuv420p`, BT.709 |
| `tests/nvenc.rs` (Windows, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right |
| `tests/nvenc_export.rs` (Windows, NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters |
| `src/nvenc/abi_tests.rs` (Windows) | FFI structs' sizes, alignments, field offsets, constants and GUIDs against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) |
| `tests/nvenc.rs` (Windows / Linux, NVIDIA) | H.264 from NVENC (1280×720, 6 Mbps, 72 frames) decodes with our decoder at worst 46.9 dB luma PSNR; IDR at 0, 24 and 48; dts / pts right |
| `tests/nvenc_export.rs` (Windows / Linux; fallback also tested without NVIDIA) | Export with hardware encoding against the software encoder through the export pipeline: the two decoded files at worst 54.8 dB luma PSNR; ffmpeg decodes the file without errors; declined cases go to the software encoder; the counters |
| `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants and GUIDs against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) |
Fixtures are made with ffmpeg into `target/fixtures/platform/` (generator only, never linked);
tests skip without ffmpeg or without a hardware decoder.
@ -191,3 +193,14 @@ Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on mac
textures on Windows); B-frames and 10-bit / HDR HEVC (Main 10) in hardware encoding; VA-API (Linux)
decoders; field-coded H.264; HEVC, 10-bit and HDR encoding with NVENC, and encoders from other
vendors on Windows (through Media Foundation); VP9 / AV1 4:4:4 and 12-bit on Windows.
### Linux NVENC sources and limits
The device binding uses NVIDIA's [CUDA Driver API 12.1 primary-context reference](https://docs.nvidia.com/cuda/archive/12.1.0/cuda-driver-api/group__CUDA__PRIMARY__CTX.html)
and the existing [Video Codec SDK 12.1 encode API](https://docs.nvidia.com/video-technologies/video-codec-sdk/12.1/nvenc-video-encoder-api-prog-guide/index.html).
It uses the first CUDA device. This backend handles H.264 only, on NVIDIA hardware, on Windows and
64-bit Linux; other formats and unsupported H.264 settings use the existing encoder selection.
Linux registers the export factory even without a driver; `register()`'s return value and
`registered()` still describe hardware **decoding**, which this backend does not provide.
Absent / old drivers decline once per export, with an informational log and
`perf.stats` → `export.hardware.declined`; hardware encoding Off never opens the driver.

View File

@ -35,7 +35,7 @@ pub mod hardware_encode;
pub mod hybrid;
#[cfg(target_os = "windows")]
pub mod media_foundation;
#[cfg(target_os = "windows")]
#[cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
pub mod nvenc;
#[cfg(target_os = "macos")]
#[allow(unsafe_code)]
@ -59,6 +59,8 @@ pub enum Availability {
/// harmless). Streams they do not take, and every stream while hardware decoding is Off, keep
/// using FilmCraft's own decoders.
pub fn register() -> Availability {
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
filmcraft_export::register_encoder(nvenc::export::factory);
#[cfg(target_os = "macos")]
{
filmcraft_codecs::register_video_decoder(videotoolbox_factory);

View File

@ -0,0 +1,130 @@
//! Devices passed to nvEncOpenEncodeSessionEx. Public APIs are safe; handles stay in this module.
use std::ffi::c_void;
#[cfg(target_os = "linux")]
pub use cuda::Device;
#[cfg(target_os = "windows")]
pub use directx::Device;
#[cfg(target_os = "windows")]
mod directx {
use super::c_void;
use windows::Win32::Foundation::HMODULE;
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
use windows::Win32::Graphics::Direct3D11::{D3D11_CREATE_DEVICE_FLAG, D3D11_SDK_VERSION, D3D11CreateDevice, ID3D11Device};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
use windows::core::Interface;
/// NVIDIA's PCI vendor id.
const NVIDIA: u32 = 0x10DE;
pub struct Device(ID3D11Device);
impl Device {
pub const KIND: u32 = super::super::ffi::NV_ENC_DEVICE_TYPE_DIRECTX;
pub fn new() -> Result<Self, String> {
nvidia_device().map(Self)
}
pub fn as_raw(&self) -> *mut c_void {
self.0.as_raw()
}
}
/// A Direct3D 11 device on the first NVIDIA adapter (what an NVENC session is opened on).
fn nvidia_device() -> Result<ID3D11Device, String> {
// SAFETY: plain COM / D3D calls with valid out-pointers; the adapter outlives the call.
unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().map_err(|e| format!("no DXGI: {e}"))?;
let mut i = 0;
while let Ok(adapter) = factory.EnumAdapters1(i) {
i += 1;
let Ok(desc) = adapter.GetDesc1() else { continue };
if desc.VendorId != NVIDIA {
continue;
}
let mut device = None;
let adapter: IDXGIAdapter = adapter.cast().map_err(|e| e.to_string())?;
if D3D11CreateDevice(
&adapter,
D3D_DRIVER_TYPE_UNKNOWN,
HMODULE::default(),
D3D11_CREATE_DEVICE_FLAG(0),
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
None,
)
.is_ok()
&& let Some(d) = device
{
return Ok(d);
}
}
}
Err("no NVIDIA adapter".into())
}
}
#[cfg(target_os = "linux")]
mod cuda {
use super::c_void;
type Init = unsafe extern "C" fn(u32) -> i32;
type GetDevice = unsafe extern "C" fn(*mut i32, i32) -> i32;
type Retain = unsafe extern "C" fn(*mut *mut c_void, i32) -> i32;
type Release = unsafe extern "C" fn(i32) -> i32;
/// One retained primary CUDA context. Retaining does not change the calling thread's
/// current context; NVENC receives the explicit context handle. No toolkit is needed.
pub struct Device {
context: *mut c_void,
ordinal: i32,
release: Release,
_library: libloading::Library,
}
impl Device {
pub const KIND: u32 = super::super::ffi::NV_ENC_DEVICE_TYPE_CUDA;
pub fn new() -> Result<Self, String> {
// SAFETY: CUDA driver API signatures from NVIDIA's public Driver API reference.
// Resolve all symbols before retaining; keep the library alive until after release.
unsafe {
let library = libloading::Library::new("libcuda.so.1").map_err(|e| format!("no NVIDIA CUDA driver: {e}"))?;
let init: libloading::Symbol<Init> = library.get(b"cuInit\0").map_err(|e| e.to_string())?;
let get: libloading::Symbol<GetDevice> = library.get(b"cuDeviceGet\0").map_err(|e| e.to_string())?;
let retain: libloading::Symbol<Retain> = library.get(b"cuDevicePrimaryCtxRetain\0").map_err(|e| e.to_string())?;
let release: Release = *library.get(b"cuDevicePrimaryCtxRelease_v2\0").map_err(|e| e.to_string())?;
let st = init(0);
if st != 0 {
return Err(format!("cuInit failed ({st})"));
}
let mut ordinal = 0;
let st = get(&mut ordinal, 0);
if st != 0 {
return Err(format!("no CUDA device ({st})"));
}
let mut context = std::ptr::null_mut();
let st = retain(&mut context, ordinal);
if st != 0 {
return Err(format!("cuDevicePrimaryCtxRetain failed ({st})"));
}
if context.is_null() {
let _ = release(ordinal);
return Err("CUDA returned a null primary context".into());
}
Ok(Self { context, ordinal, release, _library: library })
}
}
pub fn as_raw(&self) -> *mut c_void {
self.context
}
}
impl Drop for Device {
fn drop(&mut self) {
// SAFETY: balances this object's successful retain, after the NVENC session is
// destroyed. Release drops our reference, not other users' primary-context references.
unsafe {
(self.release)(self.ordinal);
}
}
}
}

View File

@ -91,6 +91,9 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
if s.field_order != FieldOrder::Progressive {
return Err("interlaced output".into());
}
if w == 0 || h == 0 || !w.is_multiple_of(2) || !h.is_multiple_of(2) {
return Err("NVENC needs nonzero even dimensions".into());
}
if w > u32::from(u16::MAX) || h > u32::from(u16::MAX) {
return Err(format!("{w}x{h} does not fit an MP4 sample entry"));
}
@ -107,7 +110,7 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
height: h,
fps,
bitrate_kbps: kbps,
max_bitrate_kbps: s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or(kbps / 2 * 3),
max_bitrate_kbps: s.max_bitrate_kbps.filter(|m| *m >= kbps).unwrap_or_else(|| kbps.saturating_add(kbps / 2)),
cbr: s.bitrate_mode == BitrateMode::Cbr,
keyint: s.keyframe_distance.filter(|k| *k > 0).unwrap_or_else(|| (f64::from(fps.0) / f64::from(fps.1) * 2.0).round().max(1.0) as u32),
profile: match s.h264_profile {
@ -123,6 +126,17 @@ fn config(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -
/// The Export encoder factory (see the module documentation).
pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettings) -> Option<Result<Box<dyn VideoEncoder>>> {
factory_with(format, w, h, rate, s, NvencH264::new)
}
fn factory_with(
format: Format,
w: u32,
h: u32,
rate: FrameRate,
s: &ExportSettings,
open: impl FnOnce(&Config) -> std::result::Result<NvencH264, String>,
) -> Option<Result<Box<dyn VideoEncoder>>> {
if s.hardware_encoding != HardwareEncoding::Auto || format != Format::H264 {
return None;
}
@ -135,7 +149,7 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
Ok(c) => c,
Err(why) => return declined(&why),
};
match NvencH264::new(&cfg) {
match open(&cfg) {
Ok(enc) => {
filmcraft_export::note_hw_encode_session();
Some(Ok(Box::new(NvencEncoder { enc, w, h, rate, y: Vec::new(), u: Vec::new(), v: Vec::new() })))
@ -148,6 +162,45 @@ pub fn factory(format: Format, w: u32, h: u32, rate: FrameRate, s: &ExportSettin
mod tests {
use super::*;
#[test]
fn off_and_unsupported_settings_never_open_the_driver() {
let declined = |format, w, h, s: &ExportSettings| {
assert!(factory_with(format, w, h, FrameRate::FPS_24, s, |_| panic!("must not open the driver")).is_none());
};
let mut s = ExportSettings { hardware_encoding: HardwareEncoding::Off, ..Default::default() };
declined(Format::H264, 640, 360, &s);
s.hardware_encoding = HardwareEncoding::Auto;
declined(Format::ProRes, 640, 360, &s);
declined(Format::H264, 641, 360, &s);
declined(Format::H264, 0, 360, &s);
s.h264_pass = H264Pass::First;
declined(Format::H264, 640, 360, &s);
s.h264_pass = H264Pass::Single;
s.field_order = FieldOrder::UpperFirst;
declined(Format::H264, 640, 360, &s);
}
#[test]
fn missing_or_rejecting_driver_declines_for_software_fallback() {
let s = ExportSettings { hardware_encoding: HardwareEncoding::Auto, ..Default::default() };
let mut attempted = false;
assert!(
factory_with(Format::H264, 640, 360, FrameRate::FPS_24, &s, |cfg| {
attempted = true;
assert_eq!((cfg.width, cfg.height), (640, 360));
Err("no NVIDIA encoder driver".into())
})
.is_none()
);
assert!(attempted);
}
#[test]
fn bitrate_ceiling_does_not_wrap() {
let s = ExportSettings { bitrate_kbps: u32::MAX, ..Default::default() };
assert_eq!(config(Format::H264, 640, 360, FrameRate::FPS_24, &s).unwrap().max_bitrate_kbps, u32::MAX);
}
#[test]
fn composition_offsets_saturate() {
assert_eq!(composition_offset(3, 1, 1001), 2002);

View File

@ -25,7 +25,7 @@
// OTHER DEALINGS IN THE SOFTWARE.
//! The NVIDIA Video Codec SDK's encode API (`nvEncodeAPI.h`, API 12.1, MIT-licensed header by
//! NVIDIA) as the driver's `nvEncodeAPI64.dll` expects it: the structures this backend fills in,
//! NVIDIA) as the driver's `nvEncodeAPI64.dll` / `libnvidia-encode.so.1` expects it: the structures this backend fills in,
//! the function table, and the constants it uses. Written from the public header; sizes and field
//! offsets are checked against a C compiler's by the generated `abi_tests.rs`.
//!
@ -38,7 +38,26 @@
use std::ffi::{c_char, c_int, c_void};
use windows::core::GUID;
// NVIDIA's public header defines this same layout on non-Windows systems.
#[repr(C)]
#[derive(Clone, Copy)]
pub struct Guid {
data1: u32,
data2: u16,
data3: u16,
data4: [u8; 8],
}
impl Guid {
pub const fn from_values(data1: u32, data2: u16, data3: u16, data4: [u8; 8]) -> Self {
Self { data1, data2, data3, data4 }
}
#[cfg(test)]
pub const fn to_u128(self) -> u128 {
((self.data1 as u128) << 96) | ((self.data2 as u128) << 80) | ((self.data3 as u128) << 64) | u64::from_be_bytes(self.data4) as u128
}
}
pub const NVENCAPI_VERSION: u32 = 12 | (1 << 24);
@ -70,6 +89,7 @@ pub const NV_ENC_ERR_ENCODER_BUSY: NvStatus = 18;
pub const NV_ENC_ERR_LOCK_BUSY: NvStatus = 13;
pub const NV_ENC_DEVICE_TYPE_DIRECTX: u32 = 0;
pub const NV_ENC_DEVICE_TYPE_CUDA: u32 = 1;
pub const NV_ENC_MEMORY_HEAP_SYSMEM_CACHED: u32 = 2;
pub const NV_ENC_BUFFER_FORMAT_NV12: u32 = 0x1;
pub const NV_ENC_PARAMS_RC_VBR: u32 = 1;
@ -96,12 +116,12 @@ pub const NV_ENC_CAPS_HEIGHT_MAX: u32 = 17;
pub const NV_ENC_CAPS_WIDTH_MIN: u32 = 45;
pub const NV_ENC_CAPS_HEIGHT_MIN: u32 = 46;
pub const NV_ENC_CODEC_H264_GUID: GUID = GUID::from_values(0x6bc82762, 0x4e63, 0x4ca4, [0xaa, 0x85, 0x1e, 0x50, 0xf3, 0x21, 0xf6, 0xbf]);
pub const NV_ENC_H264_PROFILE_BASELINE_GUID: GUID = GUID::from_values(0x0727bcaa, 0x78c4, 0x4c83, [0x8c, 0x2f, 0xef, 0x3d, 0xff, 0x26, 0x7c, 0x6a]);
pub const NV_ENC_H264_PROFILE_MAIN_GUID: GUID = GUID::from_values(0x60b5c1d4, 0x67fe, 0x4790, [0x94, 0xd5, 0xc4, 0x72, 0x6d, 0x7b, 0x6e, 0x6d]);
pub const NV_ENC_H264_PROFILE_HIGH_GUID: GUID = GUID::from_values(0xe7cbc309, 0x4f7a, 0x4b89, [0xaf, 0x2a, 0xd5, 0x37, 0xc9, 0x2b, 0xe3, 0x10]);
pub const NV_ENC_PRESET_P4_GUID: GUID = GUID::from_values(0x90a7b826, 0xdf06, 0x4862, [0xb9, 0xd2, 0xcd, 0x6d, 0x73, 0xa0, 0x86, 0x81]);
pub const NV_ENC_PRESET_P5_GUID: GUID = GUID::from_values(0x21c6e6b4, 0x297a, 0x4cba, [0x99, 0x8f, 0xb6, 0xcb, 0xde, 0x72, 0xad, 0xe3]);
pub const NV_ENC_CODEC_H264_GUID: Guid = Guid::from_values(0x6bc82762, 0x4e63, 0x4ca4, [0xaa, 0x85, 0x1e, 0x50, 0xf3, 0x21, 0xf6, 0xbf]);
pub const NV_ENC_H264_PROFILE_BASELINE_GUID: Guid = Guid::from_values(0x0727bcaa, 0x78c4, 0x4c83, [0x8c, 0x2f, 0xef, 0x3d, 0xff, 0x26, 0x7c, 0x6a]);
pub const NV_ENC_H264_PROFILE_MAIN_GUID: Guid = Guid::from_values(0x60b5c1d4, 0x67fe, 0x4790, [0x94, 0xd5, 0xc4, 0x72, 0x6d, 0x7b, 0x6e, 0x6d]);
pub const NV_ENC_H264_PROFILE_HIGH_GUID: Guid = Guid::from_values(0xe7cbc309, 0x4f7a, 0x4b89, [0xaf, 0x2a, 0xd5, 0x37, 0xc9, 0x2b, 0xe3, 0x10]);
pub const NV_ENC_PRESET_P4_GUID: Guid = Guid::from_values(0x90a7b826, 0xdf06, 0x4862, [0xb9, 0xd2, 0xcd, 0x6d, 0x73, 0xa0, 0x86, 0x81]);
pub const NV_ENC_PRESET_P5_GUID: Guid = Guid::from_values(0x21c6e6b4, 0x297a, 0x4cba, [0x99, 0x8f, 0xb6, 0xcb, 0xde, 0x72, 0xad, 0xe3]);
pub type Handle = *mut c_void;
@ -247,7 +267,7 @@ pub union NV_ENC_CODEC_CONFIG {
#[derive(Clone, Copy)]
pub struct NV_ENC_CONFIG {
pub version: u32,
pub profileGUID: GUID,
pub profileGUID: Guid,
pub gopLength: u32,
pub frameIntervalP: i32,
pub monoChromeEncoding: u32,
@ -279,8 +299,8 @@ pub struct NVENC_EXTERNAL_ME_HINT_COUNTS_PER_BLOCKTYPE {
#[derive(Clone, Copy)]
pub struct NV_ENC_INITIALIZE_PARAMS {
pub version: u32,
pub encodeGUID: GUID,
pub presetGUID: GUID,
pub encodeGUID: Guid,
pub presetGUID: Guid,
pub encodeWidth: u32,
pub encodeHeight: u32,
pub darWidth: u32,
@ -456,7 +476,7 @@ pub struct NV_ENCODE_API_FUNCTION_LIST {
pub nvEncGetEncodeGUIDs: Unused,
pub nvEncGetInputFormatCount: Unused,
pub nvEncGetInputFormats: Unused,
pub nvEncGetEncodeCaps: Option<unsafe extern "system" fn(Handle, GUID, *mut NV_ENC_CAPS_PARAM, *mut c_int) -> NvStatus>,
pub nvEncGetEncodeCaps: Option<unsafe extern "system" fn(Handle, Guid, *mut NV_ENC_CAPS_PARAM, *mut c_int) -> NvStatus>,
pub nvEncGetEncodePresetCount: Unused,
pub nvEncGetEncodePresetGUIDs: Unused,
pub nvEncGetEncodePresetConfig: Unused,
@ -488,7 +508,7 @@ pub struct NV_ENCODE_API_FUNCTION_LIST {
pub nvEncRunMotionEstimationOnly: Unused,
pub nvEncGetLastErrorString: Option<unsafe extern "system" fn(Handle) -> *const c_char>,
pub nvEncSetIOCudaStreams: Unused,
pub nvEncGetEncodePresetConfigEx: Option<unsafe extern "system" fn(Handle, GUID, GUID, u32, *mut NV_ENC_PRESET_CONFIG) -> NvStatus>,
pub nvEncGetEncodePresetConfigEx: Option<unsafe extern "system" fn(Handle, Guid, Guid, u32, *mut NV_ENC_PRESET_CONFIG) -> NvStatus>,
pub nvEncGetSequenceParamEx: Unused,
pub nvEncRestoreEncoderState: Unused,
pub nvEncLookaheadPicture: Unused,

View File

@ -1,7 +1,7 @@
//! NVIDIA NVENC H.264 hardware encoding (Windows).
//! NVIDIA NVENC H.264 hardware encoding (Windows and 64-bit Linux).
//!
//! The encoder runs on the GPU's NVENC engine through the driver's `nvEncodeAPI64.dll` (API 12.1,
//! [`ffi`]; no CUDA, no SDK to install): pictures go in as NV12 input buffers, the Annex B output
//! The encoder runs through the driver's `nvEncodeAPI64.dll` on Windows or
//! `libnvidia-encode.so.1` on Linux (API 12.1, [`ffi`]; no SDK to install): pictures go in as NV12 input buffers, the Annex B output
//! comes back as length-prefixed H.264 samples with the parameter sets split out for the `avcC`.
//! [`export`] plugs it into Export as an alternative to the software encoder.
//!
@ -10,11 +10,13 @@
//! ──► NV12 input buffer ──NVENC──► Annex B ──► length-prefixed samples + avcC
//! ```
//!
//! `unsafe` is confined to `ffi` (data) and `session` (every driver call); this module is safe
//! `unsafe` is confined to `ffi` (data), `device` (device lifetime) and `session` (driver calls); this module is safe
//! code. Hardware encoding never replaces an export that works in software: the factory declines
//! what NVENC cannot do (no NVIDIA GPU or driver, sizes, HDR, two-pass, MXF...) and the software
//! encoder takes over.
#[allow(unsafe_code)]
mod device;
pub mod export;
#[allow(unsafe_code)]
mod ffi;

View File

@ -1,4 +1,4 @@
//! An NVENC H.264 encode session: the driver library, the Direct3D 11 device it is opened on, the
//! An NVENC H.264 encode session: the driver library, the device it is opened on, the
//! encoder configuration, and the input / output buffers (every call into the driver).
//!
//! FFI module (docs/adr/0001-platform-ffi.md): every `unsafe` block has a `// SAFETY:` comment, the
@ -7,21 +7,14 @@
use std::ffi::{CStr, c_void};
use std::sync::OnceLock;
use windows::Win32::Foundation::HMODULE;
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
use windows::Win32::Graphics::Direct3D11::{D3D11_CREATE_DEVICE_FLAG, D3D11_SDK_VERSION, D3D11CreateDevice, ID3D11Device};
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
use windows::Win32::System::LibraryLoader::{GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW};
use windows::core::{Interface, PCSTR, w};
use super::device::Device;
use super::ffi::*;
/// NVIDIA's PCI vendor id.
const NVIDIA: u32 = 0x10DE;
/// The driver's entry points (loaded once).
struct Api {
list: NV_ENCODE_API_FUNCTION_LIST,
#[cfg(target_os = "linux")]
_library: libloading::Library,
}
// SAFETY: the function table is plain function pointers, immutable after loading; NVENC's API is
@ -35,7 +28,11 @@ fn api() -> Result<&'static Api, String> {
API.get_or_init(load).as_ref().map_err(Clone::clone)
}
#[cfg(target_os = "windows")]
fn load() -> Result<Api, String> {
use windows::Win32::Foundation::HMODULE;
use windows::Win32::System::LibraryLoader::{GetProcAddress, LOAD_LIBRARY_SEARCH_SYSTEM32, LoadLibraryExW};
use windows::core::{PCSTR, w};
// SAFETY: loads the driver's library from the system directory only.
let module: HMODULE =
unsafe { LoadLibraryExW(w!("nvEncodeAPI64.dll"), None, LOAD_LIBRARY_SEARCH_SYSTEM32) }.map_err(|_| "no NVIDIA encoder driver".to_string())?;
@ -64,39 +61,29 @@ fn load() -> Result<Api, String> {
}
}
/// A Direct3D 11 device on the first NVIDIA adapter (what an NVENC session is opened on).
fn nvidia_device() -> Result<ID3D11Device, String> {
// SAFETY: plain COM / D3D calls with valid out-pointers; the adapter outlives the call.
#[cfg(target_os = "linux")]
fn load() -> Result<Api, String> {
// SAFETY: runtime-load NVIDIA's driver and use the signatures from nvEncodeAPI.h.
// The library lives alongside the immutable function table, for the process lifetime.
unsafe {
let factory: IDXGIFactory1 = CreateDXGIFactory1().map_err(|e| format!("no DXGI: {e}"))?;
let mut i = 0;
while let Ok(adapter) = factory.EnumAdapters1(i) {
i += 1;
let Ok(desc) = adapter.GetDesc1() else { continue };
if desc.VendorId != NVIDIA {
continue;
}
let mut device = None;
let adapter: IDXGIAdapter = adapter.cast().map_err(|e| e.to_string())?;
if D3D11CreateDevice(
&adapter,
D3D_DRIVER_TYPE_UNKNOWN,
HMODULE::default(),
D3D11_CREATE_DEVICE_FLAG(0),
None,
D3D11_SDK_VERSION,
Some(&mut device),
None,
None,
)
.is_ok()
&& let Some(d) = device
{
return Ok(d);
}
let library = libloading::Library::new("libnvidia-encode.so.1").map_err(|e| format!("no NVIDIA encoder driver: {e}"))?;
let version: libloading::Symbol<MaxSupportedVersionFn> = library.get(b"NvEncodeAPIGetMaxSupportedVersion\0").map_err(|e| e.to_string())?;
let create: libloading::Symbol<CreateInstanceFn> = library.get(b"NvEncodeAPICreateInstance\0").map_err(|e| e.to_string())?;
let mut v = 0;
if version(&mut v) != NV_ENC_SUCCESS {
return Err("cannot query the NVENC version".into());
}
let ours = (NVENCAPI_VERSION & 0xf) << 4 | ((NVENCAPI_VERSION >> 24) & 0xf);
if v < ours {
return Err(format!("the NVIDIA driver's NVENC ({}.{}) is older than {}.{}", v >> 4, v & 0xf, ours >> 4, ours & 0xf));
}
let mut list: NV_ENCODE_API_FUNCTION_LIST = std::mem::zeroed();
list.version = NV_ENCODE_API_FUNCTION_LIST_VER;
if create(&mut list) != NV_ENC_SUCCESS {
return Err("NvEncodeAPICreateInstance failed".into());
}
Ok(Api { list, _library: library })
}
Err("no NVIDIA adapter".into())
}
/// What the encoder says it can do for H.264.
@ -159,15 +146,15 @@ struct Slot {
pub struct Session {
api: &'static Api,
enc: Handle,
// keeps the Direct3D device (the session's device) alive
_device: ID3D11Device,
// Keeps the Direct3D device / retained CUDA context alive through session destruction.
_device: Device,
slots: Vec<Slot>,
size: (u32, u32),
out_size: u32,
}
// SAFETY: an encoder session and its buffers are driver objects used from one thread at a time (the
// session is `&mut`-driven); the Direct3D device is a free-threaded COM object.
// session is `&mut`-driven); Direct3D is free-threaded, and NVENC takes an explicit CUDA context.
unsafe impl Send for Session {}
/// A readable form of a driver status.
@ -192,14 +179,14 @@ impl Session {
/// Open a session on the system's NVIDIA GPU, or say why not.
pub fn open() -> Result<Session, String> {
let api = api()?;
let device = nvidia_device()?;
// SAFETY: a zeroed parameter block with its version and the device's COM pointer; the
let device = Device::new()?;
// SAFETY: a zeroed parameter block with its version and the matching device pointer; the
// device is kept in the session for as long as the encoder lives. On failure a non-null
// handle is the driver's and is destroyed exactly once, here, before returning.
let enc = unsafe {
let mut p: NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS = std::mem::zeroed();
p.version = NV_ENC_OPEN_ENCODE_SESSION_EX_PARAMS_VER;
p.deviceType = NV_ENC_DEVICE_TYPE_DIRECTX;
p.deviceType = Device::KIND;
p.device = device.as_raw();
p.apiVersion = NVENCAPI_VERSION;
let open = api.list.nvEncOpenEncodeSessionEx.ok_or("the driver has no nvEncOpenEncodeSessionEx")?;

View File

@ -1,7 +1,7 @@
//! NVENC H.264 encoding (Windows, NVIDIA GPU): the stream decodes with our own decoder to pictures
//! close to the source, keyframes and timestamps are right, and Export takes it only when asked.
//! Skips without an NVIDIA GPU / driver with NVENC.
#![cfg(target_os = "windows")]
#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
use filmcraft_isobmff::{AvcConfig, SampleEntry};
use filmcraft_platform::nvenc::{Config, NvencH264, Packet, Profile};

View File

@ -2,7 +2,7 @@
//! pipeline: the same frames, a file both decode, counters showing which encoder ran, and what NVENC
//! declines (hardware encoding Off, two-pass, MXF...) going to the software encoder. Its own test
//! binary: the encoder registry is process-wide. Skips without an NVIDIA GPU with NVENC.
#![cfg(target_os = "windows")]
#![cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
use std::sync::Arc;
@ -83,15 +83,19 @@ fn hardware_export_matches_the_software_export() {
filmcraft_platform::register();
let (p, seq, m) = project(1280, 720, 72);
let soft_path = tmp("soft.mp4");
let before_soft = hw_encode_stats();
export(&p, seq, &settings(soft_path.clone(), HardwareEncoding::Off), &m, &Progress::default()).unwrap();
assert_eq!(hw_encode_stats().sessions, 0, "Off: no hardware encoder");
assert_eq!(hw_encode_stats(), before_soft, "Off: no hardware encoder attempt");
let hw_path = tmp("hw.mp4");
let before = hw_encode_stats();
let report = export(&p, seq, &settings(hw_path.clone(), HardwareEncoding::Auto), &m, &Progress::default()).unwrap();
let after = hw_encode_stats();
if after.sessions == before.sessions {
eprintln!("SKIPPED: no NVENC here (declined {})", after.declined - before.declined);
assert_eq!(after.declined - before.declined, 1, "Auto attempted hardware and reported the fallback");
assert_eq!(after.frames, before.frames);
assert_eq!(std::fs::read(&soft_path).unwrap(), std::fs::read(&hw_path).unwrap(), "fallback preserves software determinism");
eprintln!("no NVENC here: byte-identical software fallback verified");
return;
}
assert_eq!(report.frames, 72);
@ -135,7 +139,9 @@ fn what_nvenc_does_not_take_goes_to_the_software_encoder() {
(0, 1)
);
// an odd frame size is rounded to even by the pipeline, so NVENC still takes it
assert_eq!(run("odd.mp4", &|s| s.frame_size = Some((641, 359))), (24, 0));
let hardware_available = filmcraft_platform::nvenc::available();
let odd = run("odd.mp4", &|s| s.frame_size = Some((641, 359)));
assert_eq!(odd, if hardware_available { (24, 0) } else { (0, 1) });
// Off never touches the hardware encoder
assert_eq!(run("off.mp4", &|s| s.hardware_encoding = HardwareEncoding::Off), (0, 0));
// the decoded file of a declined export is fine

View File

@ -26,7 +26,7 @@ Containment rules:
1. The crate does not use `lints.workspace = true`. Its own `[lints]` table copies the workspace
lints except `unsafe_code = "deny"` (not `forbid`), and adds
`clippy::undocumented_unsafe_blocks = "deny"`. Only the FFI modules (`videotoolbox`, `media_foundation::gpu` / `media_foundation::mft`, and
`nvenc::ffi` / `nvenc::session` on Windows) carry `#[allow(unsafe_code)]`; the rest of the crate
`nvenc::ffi` / `nvenc::session` / `nvenc::device` on Windows and 64-bit Linux) carry `#[allow(unsafe_code)]`; the rest of the crate
(the fallback logic in `hybrid`, the decoder logic in `media_foundation`, `annexb`, `biplanar`,
the encoder logic in `nvenc`) has no `unsafe`.
2. Every `unsafe` block has a `// SAFETY:` comment saying why it is sound.
@ -101,3 +101,13 @@ there will not be one (pure Rust, clean-room: x265 is GPL), so `Format::Hevc` ex
OS has a hardware encoder. Choosing the format is the opt-in, `filmcraft_export::available` asks a
probe the platform crate registers, and what the hardware path does not take (two-pass, odd sizes,
a machine without the encoder) is an error naming the reason instead of a different encoder.
## Addendum: Linux NVENC H.264 encoding
The existing NVENC session is shared with 64-bit Linux. Only device acquisition and driver loading
are platform-specific: Windows retains the Direct3D device; Linux retains the first CUDA device's
primary context through `libcuda.so.1`. `cuDevicePrimaryCtxRetain` does not push a context onto the
calling thread's stack. The retained reference is released after the NVENC session and its buffers;
other users' references are not reset. `libnvidia-encode.so.1` stays loaded with its function table.
Both libraries come from the installed NVIDIA driver, not this project. Export registration on
Linux does not claim that a hardware decoder is available. The same opt-in and fallback rules apply.

View File

@ -263,12 +263,12 @@ file ──► codecs (MP4/MOV, MKV, audio) demux + decode, GOP-aware see
Decoders run slices on rayon, so an export worker waiting inside a decode can pick up another
frame of the same source. A request that finds the shared decoder busy decodes with a private
decoder.
- **Hardware encoding.** On Windows, `platform::nvenc::export::factory` is registered with
- **Hardware encoding.** On Windows and 64-bit Linux, `platform::nvenc::export::factory` is registered with
`filmcraft_export::register_encoder` (`register()` does this once), in front of the software H.264
encoder. It takes an export only when Export ▸ Hardware encoding (`ExportSettings.hardwareEncoding`,
`HardwareEncoding::Auto`) is Auto, which is off by default: hardware streams differ from ours, and
exports are otherwise byte-identical from run to run. NVENC (`platform::nvenc`, the driver's
`nvEncodeAPI64.dll` loaded at run time) then takes RGBA frames, converted to 4:2:0 by the software
`nvEncodeAPI64.dll` / `libnvidia-encode.so.1` loaded at run time) then takes RGBA frames, converted to 4:2:0 by the software
encoder's own conversion, and produces the H.264 samples and `avcC` of an MP4 / MOV. It declines
(the software encoder runs, counted in `export.hardware.declined`) two-pass VBR, HDR, MXF,
interlaced output, sizes outside NVENC's limits, and systems without an NVIDIA GPU or driver.