Linux: NVDEC hardware decoding for H.264 and HEVC

Adds a second Linux decoder backend, crates/platform/src/nvdec, that talks to
NVIDIA's driver directly (libnvcuvid + the CUDA driver, both loaded at run
time). It is registered in front of VA-API: what NVDEC declines still reaches
VA-API, then the software decoders. H.264 8-bit and HEVC 8/10-bit, 4:2:0
progressive.

NVIDIA's parser takes Annex B access units, so there is no host-side DPB.
Pictures are read back as NV12 / P016 through the existing biplanar path and
are bit-exact with the software decoders. The CUDA context is the one
nvenc::device already retains; it gains push/pop and copy-to-host. A seek
recreates the parser and keeps the decoder.

Also fixes tests/setting.rs, which expected the Media Foundation backend name
on Linux, and updates the Hardware decoding tooltip and its Spanish row.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
SilentYeti 2026-10-09 23:17:11 -07:00
parent 7bd7621262
commit 03a840d28b
18 changed files with 1472 additions and 17 deletions

View File

@ -21,7 +21,7 @@ panic path, and fix a crash before building on top of it. The cross-app standard
code alone (a literal that always parses): a single-item `#[allow(clippy::expect_used)]` with the reason.
3. **No `unsafe`** (`unsafe_code = "forbid"` for the workspace). The single exception is
`crates/platform`, which needs `unsafe` to call the operating system's media APIs (hardware
video decoding through VideoToolbox, Media Foundation / Direct3D 11 and VA-API; hardware encoding) and does nothing else. It
video decoding through VideoToolbox, Media Foundation / Direct3D 11, VA-API and NVDEC; hardware encoding) and does nothing else. It
uses `unsafe_code = "deny"` with `#[allow(unsafe_code)]` only on its FFI modules, a
`// SAFETY:` comment on every `unsafe` block, a safe `Result`-returning public API, and the
pure-Rust decoder as the tested fallback ([ADR 0001](docs/adr/0001-platform-ffi.md)).

View File

@ -287,7 +287,7 @@ We track two numbers ([ROADMAP.md](ROADMAP.md#honest-assessment-2026-10-05)):
The biggest gaps today:
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
- **Speed on big footage.** Hardware decoding works on macOS and Windows, and for H.264 and HEVC on Linux (VA-API, or NVDEC with NVIDIA's driver; VP9 and AV1 decode in software there). Blend modes and the most common effects run on the GPU, but Lumetri, keys and export rendering still run on the CPU; H.264 encoding can use the hardware encoder on macOS and on Windows and Linux with an NVIDIA GPU (opt-in, Export ▸ Hardware encoding), and H.265 export is hardware-only on both (HDR sequences export Main 10 PQ / HLG with NVENC; VideoToolbox writes 8-bit SDR) ([#30](https://github.com/storytold/filmcraft/issues/30)).
- **No plugins.** No VST3 / Audio Units or OpenFX hosting.
- **Delivery codecs.** H.264 is our only software delivery-codec export; H.265 exports only through a hardware encoder (NVIDIA on Windows: Main 8-bit SDR and Main 10 HDR PQ / HLG; VideoToolbox on macOS: 8-bit Main, SDR); no AV1 export yet.
- **Real-world media and platforms.** Our decoders are bit-exact on conformance streams, but camera and phone files in the wild are less tested. Windows and Linux get far less testing than macOS.

View File

@ -461,7 +461,7 @@ mod format_tests {
#[test]
fn startup_registers_the_hardware_decoders() {
let hardware = super::register_hardware_decoders();
// always on macOS and Windows; on Linux when a VA-API driver is there
// always on macOS and Windows; on Linux when a VA-API driver or NVIDIA's driver (NVDEC) is there
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");

View File

@ -329,7 +329,7 @@ mod tests {
fn startup_registers_the_hardware_decoders_without_a_logger() {
assert!(!log::log_enabled!(log::Level::Info));
let hardware = super::register_hardware_decoders();
// always on macOS and Windows; on Linux when a VA-API driver is there
// always on macOS and Windows; on Linux when a VA-API driver or NVIDIA's driver (NVDEC) is there
let expected = cfg!(any(target_os = "macos", target_os = "windows"))
|| (cfg!(target_os = "linux") && matches!(hardware, filmcraft_platform::Availability::Available(_)));
assert_eq!(filmcraft_platform::registered(), expected, "{hardware:?}");

View File

@ -966,7 +966,7 @@ static CATEGORIES: &[Category] = &[
b("playback.draftDecode", "Draft decoding at reduced playback resolution (H.264: faster, some frames less filtered)", true),
f("playback.hardwareDecoding", "Hardware decoding", Kind::Choice(HW_DECODE), true),
Row::Note(
"Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.",
"Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened.",
),
],
},

View File

@ -1,6 +1,6 @@
[package]
name = "filmcraft-platform"
description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS, Media Foundation on Windows, VA-API on Linux), with transparent fallback to the pure-Rust decoders"
description = "OS media integration: hardware video decoding through the operating system's codecs (VideoToolbox on macOS, Media Foundation on Windows, VA-API and NVDEC on Linux), with transparent fallback to the pure-Rust decoders"
version.workspace = true
edition.workspace = true
license.workspace = true

View File

@ -74,6 +74,16 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox")
ends it with an error, since a hardware stream cannot be finished in software. The same session,
ring and NV12 path encode H.265, see [Hardware H.265 (HEVC) encoding (Windows,
NVENC)](#hardware-h265-hevc-encoding-windows-nvenc).
- **Linux: NVDEC, H.264 (`avcC`) and HEVC (`hvcC`)** on NVIDIA's proprietary driver (`nvdec/`): H.264
8-bit and HEVC 8- / 10-bit, 4:2:0 progressive. `libnvcuvid.so.1` and `libcuda.so.1` are loaded at
run time; without them (or without a device) nothing is registered. NVIDIA's own parser takes the
stream as Annex B access units, so there is no host-side DPB: `cuvid::Session` creates the decoder
in the parser's sequence callback, decodes in its picture callback, and reads each displayed
picture back (NV12 / P016) through the CUDA driver into the same planes as the other backends.
The factory goes in front of VA-API's: what NVDEC declines still reaches VA-API (another GPU),
then the software decoders. After a start at a CRA picture its RASL pictures are not fed (no
decoder outputs them, and the parser would otherwise show the CRA picture with the last one's
timestamp). A seek recreates the parser and keeps the decoder.
- **Linux: VA-API, H.264 (`avcC`) and HEVC (`hvcC`)**: H.264 8-bit 4:2:0 progressive, Constrained
Baseline / Main / High; HEVC Main, Main 10 and Main Still Picture, 4:2:0 8- and 10-bit (`vaapi/`), on Intel (iHD, i965), AMD and other Mesa drivers. `libva.so.2` and `libva-drm.so.2`
are loaded at run time (`vaapi/va.rs`; no libva headers needed to build), and each decoder opens
@ -288,6 +298,8 @@ level 4 High tier, which many hardware decoders refuse; it is now level 4.1 Main
| `src/nvenc/abi_tests.rs` (Windows / Linux) | FFI structs' sizes, alignments, field offsets, constants, GUIDs and the bit-field masks of the `flags` words (H.264, HEVC, and the HEVC picture parameters with the SEI payload array) against a C compiler's view of NVIDIA's `nvEncodeAPI.h` (12.1) |
| `src/nvenc/{mod,hevc,export}.rs` unit tests (Windows) | HEVC NAL types, stripping and parameter-set splitting (including `IDR_N_LP`, whose header byte reads as a PPS in H.264, and one-byte or truncated NAL units); the `hvcC` built from real VPS / SPS / PPS (Main and Main 10: accepted as what was asked for, refused as the other, other depths refused), every truncation and bit flip of the SPS, wrong profile, size and NAL types; HEVC levels; P010 layout (shift, little-endian, interleaving, pitch in bytes, narrow pitches and short buffers); the 10-bit capability decline; the SEI messages |
| `crates/export/src/hdr_tests.rs` | `rgbf_to_yuv420_10`: exact codes for black, white, grey and the BT.2020 primaries and secondaries, NaN / infinity / out-of-range, odd sizes, wrong lengths and overflowing sizes; the HDR probe registry; which exports are HDR (HEVC SDR without a probe, HDR with one, `settings.sdr` forcing SDR); the SEI of the software H.264 encoder equals `ColorSignal::static_metadata` |
| `tests/nvdec.rs` (Linux, NVIDIA driver) | H.264 High, HEVC Main (open GOP: CRA + RASL) and Main 10 at 640x360, H.264 and HEVC Main 10 at 1080p: bit-exact with the software decoders over the whole stream, after `reset` + reseek to every sync sample and again after the resets; a forced mid-stream failure continues in software with identical output; interlaced and 10-bit H.264 and 4:2:2 HEVC are declined, as is everything while Hardware decoding is Off; damaged samples (bit flips, truncation, corrupt lengths) never crash or hang |
| `src/nvdec/abi_tests.rs` | FFI structs' sizes, alignments, field offsets and constants against a C compiler's view of NVIDIA's `cuviddec.h` / `nvcuvid.h` (nv-codec-headers) |
| `tests/vaapi.rs` (Linux, VA-API) | HEVC Main (open GOP: CRA + RASL), Main 10, scaling lists, 3 slices with WPP (10-bit), weighted prediction with 4 references, transform skip + AMP + B-pyramid (10-bit, open GOP), Main / Main 10 at 1080p and 2160p (compared picture by picture, little memory); H.264 High (B-pyramid), Constrained Baseline with 3 slices per picture, Main with explicit weighted prediction (P and B) and 4 references, temporal direct, custom scaling matrices (JVT) with the 8x8 transform and 2 slices, an open-GOP stream, and 1080p / 2160p, 640×360 (coded 368: cropping): every picture **bit-exact** with our software decoder, same pts order, count, colour and aspect, also after `reset` + reseek to every later sync sample (after an open-GOP I picture, from the seek point on), a mid-stream `flush` with decoding carrying on, and a full pass after resets; forced mid-stream failures (`VaDecoder::fail_after`) at five points continue with the software decoder's exact output; interlaced and 10-bit H.264, 4:2:2 HEVC and the Off setting are declined; an HEVC mid-GOP flush is not checked (it ends the stream, as in software); seeded mutation of samples (through the hybrid and straight into the hardware decoder) and of `avcC` records never panics or hangs; 40 decoders created and dropped; concurrent decoders; a decoder moving between threads |
| `src/vaapi/tests.rs` (every OS) | The H.264 and HEVC front ends with a recording stand-in for the hardware, on x264 streams (B-pyramid, weighted prediction, 4 slices, open GOP with temporal direct) and x265 streams (open GOP with RASL, B-pyramid, Main 10 with 2 slices and weighted prediction): output order and pts are the software decoder's, also after seeks and a mid-stream flush; every buffer is consistent (current surface free, references decoded and listed once, reference lists of the active length, slice data offsets inside the slice); damaged samples are errors |
| `src/vaapi/abi_tests.rs` | FFI structs' sizes, alignments, field offsets, constants and bit-field positions against a C compiler's view of libva's `va.h` and `va_dec_hevc.h` (VA-API 1.23) |

View File

@ -4,8 +4,8 @@
//! [`register`] puts the platform's hardware decoder factory in front of FilmCraft's own decoders
//! (`filmcraft_codecs::register_video_decoder`). Today that is VideoToolbox on macOS for H.264
//! (`avcC`) and HEVC (`hvcC`) streams, 8- and 10-bit, 4:2:0 and 4:2:2; Media Foundation / DXVA on
//! Windows; VA-API on Linux for H.264 ([`vaapi`]); on other systems (and on Linux without a VA-API
//! driver) registration does nothing and reports [`Availability::Unavailable`]. It also registers a
//! Windows; NVDEC ([`nvdec`], NVIDIA's driver) and VA-API ([`vaapi`]) on Linux for H.264 and HEVC; on other systems (and on
//! Linux with neither) registration does nothing and reports [`Availability::Unavailable`]. It also registers a
//! hardware H.264 encoder factory (`filmcraft_export::register_encoder`) that only acts when an
//! export asks for it (`ExportSettings::hardware_encoding` = `Auto`), see [`hardware_encode`]; on
//! macOS and on Windows (NVENC, [`nvenc`]) it also makes the H.265 export format available, and on Windows
@ -39,6 +39,8 @@ pub mod hardware_encode;
pub mod hybrid;
#[cfg(target_os = "windows")]
pub mod media_foundation;
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
pub mod nvdec;
#[cfg(any(target_os = "windows", all(target_os = "linux", target_pointer_width = "64")))]
pub mod nvenc;
#[cfg_attr(not(target_os = "linux"), allow(dead_code))]
@ -101,9 +103,9 @@ pub fn register() -> Availability {
}
#[cfg(target_os = "linux")]
{
match vaapi::va::probe() {
static ONCE: std::sync::Once = std::sync::Once::new();
let mut available = match vaapi::va::probe() {
Ok(driver) => {
static ONCE: std::sync::Once = std::sync::Once::new();
ONCE.call_once(|| log::info!("hardware decoding through VA-API: {driver}"));
filmcraft_codecs::register_video_decoder(vaapi_factory);
filmcraft_codecs::hw::set_hw_backend("VA-API");
@ -111,9 +113,23 @@ pub fn register() -> Availability {
}
Err(why) => {
log::info!("no VA-API hardware decoding: {why}");
Availability::Unavailable("no VA-API driver (libva and a DRM render node)")
Availability::Unavailable("no VA-API driver (libva and a DRM render node) and no NVIDIA driver (NVDEC)")
}
};
// NVIDIA's proprietary driver has no VA-API of its own: NVDEC goes in front, and what it
// declines still reaches VA-API (a second GPU) and then the software decoders
#[cfg(target_pointer_width = "64")]
match nvdec::cuvid::probe() {
Ok(driver) => {
static NVDEC: std::sync::Once = std::sync::Once::new();
NVDEC.call_once(|| log::info!("hardware decoding through NVDEC: {driver}"));
filmcraft_codecs::register_video_decoder(nvdec_factory);
filmcraft_codecs::hw::set_hw_backend("NVDEC");
available = Availability::Available("NVDEC");
}
Err(why) => log::info!("no NVDEC hardware decoding: {why}"),
}
available
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
@ -133,6 +149,10 @@ pub fn registered() -> bool {
}
#[cfg(target_os = "linux")]
{
#[cfg(target_pointer_width = "64")]
if filmcraft_codecs::video_decoder_registered(nvdec_factory) {
return true;
}
filmcraft_codecs::video_decoder_registered(vaapi_factory)
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
@ -154,7 +174,13 @@ pub fn hardware_decoder_for(entry: &filmcraft_isobmff::SampleEntry) -> bool {
}
#[cfg(target_os = "linux")]
{
filmcraft_codecs::hw::NalStreamInfo::from_entry(entry).and_then(|r| r.ok()).is_some_and(|info| vaapi::VaDecoder::new(info).is_ok())
filmcraft_codecs::hw::NalStreamInfo::from_entry(entry).and_then(|r| r.ok()).is_some_and(|info| {
#[cfg(target_pointer_width = "64")]
if nvdec::NvDecoder::new(info.clone()).is_ok() {
return true;
}
vaapi::VaDecoder::new(info).is_ok()
})
}
#[cfg(not(any(target_os = "macos", target_os = "windows", target_os = "linux")))]
{
@ -218,3 +244,24 @@ pub fn vaapi_factory(entry: &filmcraft_isobmff::SampleEntry) -> Option<filmcraft
}
}
}
/// The NVDEC factory: a [`HybridDecoder`] around [`nvdec::NvDecoder`] for H.264 and HEVC streams
/// this system's NVIDIA GPU decodes, `None` otherwise (VA-API and the software decoders keep those).
#[cfg(all(target_os = "linux", target_pointer_width = "64"))]
pub fn nvdec_factory(entry: &filmcraft_isobmff::SampleEntry) -> Option<filmcraft_codecs::Result<Box<dyn filmcraft_codecs::VideoDecoder>>> {
if !filmcraft_codecs::hw::hardware_decoding() {
return None;
}
let info = filmcraft_codecs::hw::NalStreamInfo::from_entry(entry)?.ok()?;
match nvdec::NvDecoder::new(info.clone()) {
Ok(nv) => Some(Ok(Box::new(HybridDecoder::new(Box::new(nv), entry.clone(), info)))),
Err(why) => {
log::info!("NVDEC declined {} video: {why}", entry.codec.name());
// with VA-API behind this factory, that one counts the decline (or takes the stream)
if !filmcraft_codecs::video_decoder_registered(vaapi_factory) {
filmcraft_codecs::hw::note_hw_declined();
}
None
}
}
}

View File

@ -0,0 +1,130 @@
//! Linux LP64 layouts generated with GCC from the installed MIT-licensed NVIDIA
//! `dynlink_cuda.h`, `dynlink_cuviddec.h` and `dynlink_nvcuvid.h` (NVDEC API 13.1).
//! Regenerate with a C program printing sizeof, _Alignof, offsetof for every field
//! below, constant values, and the video-signal byte with each bit-field set alone.
use super::ffi::*;
use std::mem::{align_of, offset_of, size_of};
#[test]
fn layouts_match_the_header() {
assert_eq!((size_of::<CUVIDEOFORMAT>(), align_of::<CUVIDEOFORMAT>()), (64, 4));
assert_eq!(offset_of!(CUVIDEOFORMAT, codec), 0);
assert_eq!(offset_of!(CUVIDEOFORMAT, frame_rate), 4);
assert_eq!(offset_of!(CUVIDEOFORMAT, progressive_sequence), 12);
assert_eq!(offset_of!(CUVIDEOFORMAT, bit_depth_luma_minus8), 13);
assert_eq!(offset_of!(CUVIDEOFORMAT, bit_depth_chroma_minus8), 14);
assert_eq!(offset_of!(CUVIDEOFORMAT, min_num_decode_surfaces), 15);
assert_eq!(offset_of!(CUVIDEOFORMAT, coded_width), 16);
assert_eq!(offset_of!(CUVIDEOFORMAT, coded_height), 20);
assert_eq!(offset_of!(CUVIDEOFORMAT, display_area), 24);
assert_eq!(offset_of!(CUVIDEOFORMAT, chroma_format), 40);
assert_eq!(offset_of!(CUVIDEOFORMAT, bitrate), 44);
assert_eq!(offset_of!(CUVIDEOFORMAT, display_aspect_ratio), 48);
assert_eq!(offset_of!(CUVIDEOFORMAT, video_signal_description), 56);
assert_eq!(offset_of!(CUVIDEOFORMAT, seqhdr_data_length), 60);
assert_eq!((size_of::<CUVIDDECODECAPS>(), align_of::<CUVIDDECODECAPS>()), (88, 4));
assert_eq!(offset_of!(CUVIDDECODECAPS, eCodecType), 0);
assert_eq!(offset_of!(CUVIDDECODECAPS, eChromaFormat), 4);
assert_eq!(offset_of!(CUVIDDECODECAPS, nBitDepthMinus8), 8);
assert_eq!(offset_of!(CUVIDDECODECAPS, reserved1), 12);
assert_eq!(offset_of!(CUVIDDECODECAPS, bIsSupported), 24);
assert_eq!(offset_of!(CUVIDDECODECAPS, nNumNVDECs), 25);
assert_eq!(offset_of!(CUVIDDECODECAPS, nOutputFormatMask), 26);
assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxWidth), 28);
assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxHeight), 32);
assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxMBCount), 36);
assert_eq!(offset_of!(CUVIDDECODECAPS, nMinWidth), 40);
assert_eq!(offset_of!(CUVIDDECODECAPS, nMinHeight), 42);
assert_eq!(offset_of!(CUVIDDECODECAPS, bIsHistogramSupported), 44);
assert_eq!(offset_of!(CUVIDDECODECAPS, nCounterBitDepth), 45);
assert_eq!(offset_of!(CUVIDDECODECAPS, nMaxHistogramBins), 46);
assert_eq!(offset_of!(CUVIDDECODECAPS, bIsDecodeStatsSupported), 48);
assert_eq!(offset_of!(CUVIDDECODECAPS, reserved4), 49);
assert_eq!(offset_of!(CUVIDDECODECAPS, reserved3), 52);
assert_eq!((size_of::<CUVIDDECODECREATEINFO>(), align_of::<CUVIDDECODECREATEINFO>()), (176, 8));
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulWidth), 0);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulHeight), 8);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulNumDecodeSurfaces), 16);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, CodecType), 24);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ChromaFormat), 28);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulCreationFlags), 32);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, bitDepthMinus8), 40);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulIntraDecodeOnly), 48);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulMaxWidth), 56);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulMaxHeight), 64);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, Reserved1), 72);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, display_area), 80);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, OutputFormat), 88);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, DeinterlaceMode), 92);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulTargetWidth), 96);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulTargetHeight), 104);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, ulNumOutputSurfaces), 112);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, vidLock), 120);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, target_rect), 128);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, enableHistogram), 136);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, enableDecodeFeatures), 144);
assert_eq!(offset_of!(CUVIDDECODECREATEINFO, Reserved2), 152);
assert_eq!((size_of::<CUVIDPARSERPARAMS>(), align_of::<CUVIDPARSERPARAMS>()), (136, 8));
assert_eq!(offset_of!(CUVIDPARSERPARAMS, CodecType), 0);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulMaxNumDecodeSurfaces), 4);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulClockRate), 8);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulErrorThreshold), 12);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, ulMaxDisplayDelay), 16);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, flags), 20);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, uReserved1), 24);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pUserData), 40);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnSequenceCallback), 48);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnDecodePicture), 56);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnDisplayPicture), 64);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnGetOperatingPoint), 72);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pfnGetSEIMsg), 80);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pvReserved2), 88);
assert_eq!(offset_of!(CUVIDPARSERPARAMS, pExtVideoInfo), 128);
assert_eq!((size_of::<CUVIDSOURCEDATAPACKET>(), align_of::<CUVIDSOURCEDATAPACKET>()), (32, 8));
assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, flags), 0);
assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, payload_size), 8);
assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, payload), 16);
assert_eq!(offset_of!(CUVIDSOURCEDATAPACKET, timestamp), 24);
assert_eq!((size_of::<CUVIDPARSERDISPINFO>(), align_of::<CUVIDPARSERDISPINFO>()), (24, 8));
assert_eq!(offset_of!(CUVIDPARSERDISPINFO, picture_index), 0);
assert_eq!(offset_of!(CUVIDPARSERDISPINFO, progressive_frame), 4);
assert_eq!(offset_of!(CUVIDPARSERDISPINFO, top_field_first), 8);
assert_eq!(offset_of!(CUVIDPARSERDISPINFO, repeat_first_field), 12);
assert_eq!(offset_of!(CUVIDPARSERDISPINFO, timestamp), 16);
assert_eq!((size_of::<CUVIDPROCPARAMS>(), align_of::<CUVIDPROCPARAMS>()), (264, 8));
assert_eq!(offset_of!(CUVIDPROCPARAMS, progressive_frame), 0);
assert_eq!(offset_of!(CUVIDPROCPARAMS, second_field), 4);
assert_eq!(offset_of!(CUVIDPROCPARAMS, top_field_first), 8);
assert_eq!(offset_of!(CUVIDPROCPARAMS, unpaired_field), 12);
assert_eq!(offset_of!(CUVIDPROCPARAMS, reserved_flags), 16);
assert_eq!(offset_of!(CUVIDPROCPARAMS, reserved_zero), 20);
assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_input_dptr), 24);
assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_input_pitch), 32);
assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_input_format), 36);
assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_output_dptr), 40);
assert_eq!(offset_of!(CUVIDPROCPARAMS, raw_output_pitch), 48);
assert_eq!(offset_of!(CUVIDPROCPARAMS, Reserved1), 52);
assert_eq!(offset_of!(CUVIDPROCPARAMS, output_stream), 56);
assert_eq!(offset_of!(CUVIDPROCPARAMS, Reserved), 64);
assert_eq!(offset_of!(CUVIDPROCPARAMS, histogram_dptr), 248);
assert_eq!(offset_of!(CUVIDPROCPARAMS, pCuvidProcExt), 256);
assert_eq!((size_of::<FrameRate>(), align_of::<FrameRate>()), (8, 4));
assert_eq!((size_of::<Rect>(), align_of::<Rect>()), (16, 4));
assert_eq!((size_of::<ShortRect>(), align_of::<ShortRect>()), (8, 2));
assert_eq!((size_of::<AspectRatio>(), align_of::<AspectRatio>()), (8, 4));
assert_eq!((size_of::<VideoSignal>(), align_of::<VideoSignal>()), (4, 1));
}
#[test]
fn constants_match_the_header() {
assert_eq!(cudaVideoCodec_H264 as i64, 4);
assert_eq!(cudaVideoCodec_HEVC as i64, 8);
assert_eq!(cudaVideoChromaFormat_420 as i64, 1);
assert_eq!(cudaVideoSurfaceFormat_NV12 as i64, 0);
assert_eq!(cudaVideoSurfaceFormat_P016 as i64, 1);
assert_eq!(cudaVideoDeinterlaceMode_Weave as i64, 0);
assert_eq!(cudaVideoCreate_PreferCUVID as i64, 4);
assert_eq!(CUVID_PKT_ENDOFSTREAM as i64, 1);
assert_eq!(CUVID_PKT_TIMESTAMP as i64, 2);
assert_eq!(CUVID_PKT_ENDOFPICTURE as i64, 8);
assert_eq!(7u8, 7);
assert_eq!(1u8 << 3, 8);
}

View File

@ -0,0 +1,675 @@
//! Synchronous NVDEC parser callbacks and CUDA readback. All driver handles stay here.
use std::cell::Cell;
use std::ffi::c_void;
use std::panic::{AssertUnwindSafe, catch_unwind};
use std::ptr;
use filmcraft_codecs::hw::NalCodec;
use filmcraft_frame::VideoFrame;
use super::ffi::*;
use crate::biplanar::{Biplanar, Geometry, to_frame};
use crate::nvenc::device::Device;
const MAX_SURFACES: u32 = 64;
const MAX_FRAME_BYTES: usize = 256 * 1024 * 1024;
const MAX_PACKET_BYTES: usize = 64 * 1024 * 1024;
const MAX_READY_BYTES: usize = 512 * 1024 * 1024;
#[derive(Clone, Copy)]
struct Api {
caps: GetDecoderCaps,
create_decoder: CreateDecoder,
destroy_decoder: DestroyDecoder,
decode: DecodePicture,
map: MapVideoFrame,
unmap: UnmapVideoFrame,
create_parser: CreateVideoParser,
parse: ParseVideoData,
destroy_parser: DestroyVideoParser,
}
impl Api {
fn load() -> Result<(Self, libloading::Library), String> {
// SAFETY: symbols have the signatures in NVIDIA's public headers. The caller
// retains the returned library until all handles using these pointers are destroyed.
unsafe {
let library = libloading::Library::new("libnvcuvid.so.1").map_err(|e| format!("no NVIDIA NVDEC library: {e}"))?;
let api = Self {
caps: *library.get(b"cuvidGetDecoderCaps\0").map_err(|e| e.to_string())?,
create_decoder: *library.get(b"cuvidCreateDecoder\0").map_err(|e| e.to_string())?,
destroy_decoder: *library.get(b"cuvidDestroyDecoder\0").map_err(|e| e.to_string())?,
decode: *library.get(b"cuvidDecodePicture\0").map_err(|e| e.to_string())?,
map: *library.get(b"cuvidMapVideoFrame64\0").map_err(|e| e.to_string())?,
unmap: *library.get(b"cuvidUnmapVideoFrame64\0").map_err(|e| e.to_string())?,
create_parser: *library.get(b"cuvidCreateVideoParser\0").map_err(|e| e.to_string())?,
parse: *library.get(b"cuvidParseVideoData\0").map_err(|e| e.to_string())?,
destroy_parser: *library.get(b"cuvidDestroyVideoParser\0").map_err(|e| e.to_string())?,
};
Ok((api, library))
}
}
}
fn status(code: i32, operation: &str) -> Result<(), String> {
if code == 0 { Ok(()) } else { Err(format!("{operation} failed ({code})")) }
}
// Only POD structs from ffi.rs are zeroed: no Rust references or invalid enum discriminants.
fn caps(codec: i32, bits: u32) -> CUVIDDECODECAPS {
// SAFETY: this C struct contains only integers; zero is a valid value for every field.
let mut caps: CUVIDDECODECAPS = unsafe { std::mem::zeroed() };
caps.eCodecType = codec;
caps.eChromaFormat = cudaVideoChromaFormat_420;
caps.nBitDepthMinus8 = bits.saturating_sub(8);
caps
}
fn output_format(bits: u32) -> i32 {
if bits == 10 { cudaVideoSurfaceFormat_P016 } else { cudaVideoSurfaceFormat_NV12 }
}
fn validate(coded: (u32, u32), geometry: Geometry, reorder: usize) -> Result<u32, String> {
let (w, h) = coded;
let (x, y, cw, ch) = geometry.crop;
if w == 0 || h == 0 || w > 8192 || h > 8192 || !w.is_multiple_of(2) || !h.is_multiple_of(2) {
return Err(format!("NVDEC coded size {w}x{h} is not supported"));
}
if !matches!(geometry.bits, 8 | 10) || cw == 0 || ch == 0 || x.checked_add(cw).is_none_or(|end| end > w) || y.checked_add(ch).is_none_or(|end| end > h) {
return Err("NVDEC picture depth or crop is invalid".into());
}
let delay = u32::try_from(reorder).map_err(|_| "NVDEC reorder count overflows")?;
if delay >= MAX_SURFACES {
return Err("NVDEC reorder count is too large".into());
}
Ok(delay)
}
fn check_caps(caps: &CUVIDDECODECAPS, coded: (u32, u32), bits: u32) -> Result<(), String> {
let (w, h) = coded;
let blocks = w.div_ceil(16).checked_mul(h.div_ceil(16)).ok_or("NVDEC macroblock count overflows")?;
let mask = 1u16.checked_shl(output_format(bits) as u32).ok_or("NVDEC output format overflows")?;
if caps.bIsSupported == 0
|| caps.nOutputFormatMask & mask == 0
|| w < u32::from(caps.nMinWidth)
|| h < u32::from(caps.nMinHeight)
|| w > caps.nMaxWidth
|| h > caps.nMaxHeight
|| blocks > caps.nMaxMBCount
{
return Err(format!("GPU does not support NVDEC codec {} at {w}x{h}, {bits}-bit 4:2:0", caps.eCodecType));
}
Ok(())
}
struct State {
api: Api,
// Points to Session's separately boxed, immutable Device (stable across moves).
device: *const Device,
decoder: *mut c_void,
codec: i32,
coded: (u32, u32),
geometry: Geometry,
delay: u32,
surfaces: u32,
mapped: Cell<Option<u64>>,
pending: Vec<CUVIDPARSERDISPINFO>,
ready: Vec<(i64, VideoFrame)>,
error: Option<String>,
}
impl State {
fn sequence(&mut self, format: &CUVIDEOFORMAT) -> Result<i32, String> {
// The driver rounds the coded size up to its own block size (HEVC 640x360 comes back as
// 640x368): take its size, which is the mapped surface's, when it holds the stream's.
let driver = (format.coded_width, format.coded_height);
let fits = |d: u32, c: u32| d >= c && d - c < 64;
if !(fits(driver.0, self.coded.0) && fits(driver.1, self.coded.1)) || (!self.decoder.is_null() && driver != self.coded) {
return Err(format!("NVDEC sequence is {}x{}, the stream {}x{}", driver.0, driver.1, self.coded.0, self.coded.1));
}
self.coded = driver;
if format.codec != self.codec
|| format.chroma_format != cudaVideoChromaFormat_420
|| format.progressive_sequence != 1
|| u32::from(format.bit_depth_luma_minus8).checked_add(8) != Some(self.geometry.bits)
|| format.bit_depth_chroma_minus8 != format.bit_depth_luma_minus8
{
return Err("NVDEC sequence differs from the stream configuration".into());
}
let surfaces = u32::from(format.min_num_decode_surfaces).max(self.delay.checked_add(1).ok_or("NVDEC surface count overflows")?).max(2);
if format.min_num_decode_surfaces == 0 || surfaces > MAX_SURFACES {
return Err("NVDEC reported an invalid decode surface count".into());
}
if !self.decoder.is_null() {
if surfaces > self.surfaces {
return Err("NVDEC sequence needs more decode surfaces".into());
}
return i32::try_from(self.surfaces).map_err(|_| "NVDEC surface count overflows".into());
}
// SAFETY: plain C data (integers and pointers) permits an all-zero initialization.
let mut info: CUVIDDECODECREATEINFO = unsafe { std::mem::zeroed() };
info.ulWidth = u64::from(self.coded.0);
info.ulHeight = u64::from(self.coded.1);
info.ulNumDecodeSurfaces = u64::from(surfaces);
info.CodecType = self.codec;
info.ChromaFormat = cudaVideoChromaFormat_420;
info.ulCreationFlags = cudaVideoCreate_PreferCUVID;
info.bitDepthMinus8 = u64::from(self.geometry.bits.saturating_sub(8));
info.ulMaxWidth = info.ulWidth;
info.ulMaxHeight = info.ulHeight;
let right = i16::try_from(self.coded.0).map_err(|_| "NVDEC width overflows")?;
let bottom = i16::try_from(self.coded.1).map_err(|_| "NVDEC height overflows")?;
// Explicit full coded rectangles prevent the parser's display crop being applied twice.
info.display_area = ShortRect { left: 0, top: 0, right, bottom };
info.target_rect = info.display_area;
info.OutputFormat = output_format(self.geometry.bits);
info.DeinterlaceMode = cudaVideoDeinterlaceMode_Weave;
info.ulTargetWidth = info.ulWidth;
info.ulTargetHeight = info.ulHeight;
info.ulNumOutputSurfaces = 1;
// SAFETY: the context is current throughout the parser call; info and the out-pointer
// are live. The decoder is owned by Session until explicitly destroyed.
status(unsafe { (self.api.create_decoder)(&mut self.decoder, &mut info) }, "cuvidCreateDecoder")?;
if self.decoder.is_null() {
return Err("NVDEC returned a null decoder".into());
}
self.surfaces = surfaces;
i32::try_from(surfaces).map_err(|_| "NVDEC surface count overflows".into())
}
fn display(&mut self, info: &CUVIDPARSERDISPINFO) -> Result<i32, String> {
if self.decoder.is_null()
|| info.picture_index < 0
|| u32::try_from(info.picture_index).is_ok_and(|i| i >= self.surfaces)
|| info.progressive_frame != 1
|| info.repeat_first_field != 0
{
return Err("NVDEC reported an invalid or non-progressive display picture".into());
}
if self.pending.len() >= MAX_SURFACES as usize {
return Err("NVDEC display queue exceeds its surface limit".into());
}
self.pending.try_reserve(1).map_err(|e| format!("NVDEC display allocation failed: {e}"))?;
self.pending.push(*info);
Ok(1)
}
fn drain(&mut self) -> Result<(), String> {
// Copy queued displays before another decode can recycle their picture indices.
for info in std::mem::take(&mut self.pending) {
self.map_picture(&info)?;
}
Ok(())
}
fn map_picture(&mut self, info: &CUVIDPARSERDISPINFO) -> Result<(), String> {
// SAFETY: this C struct contains integers and nullable pointers only.
let mut proc: CUVIDPROCPARAMS = unsafe { std::mem::zeroed() };
proc.progressive_frame = 1;
proc.top_field_first = info.top_field_first;
let (mut address, mut pitch) = (0u64, 0u32);
// SAFETY: the validated picture index belongs to the live decoder. Both output
// pointers and proc are writable, and Session has pushed the context on this thread.
status(unsafe { (self.api.map)(self.decoder, info.picture_index, &mut address, &mut pitch, &mut proc) }, "cuvidMapVideoFrame64")?;
self.mapped.set(Some(address));
let mapped = Mapped { api: self.api, decoder: self.decoder, address, outstanding: &self.mapped, active: true };
let frame = self.readback(address, pitch);
mapped.unmap()?;
let frame = frame?;
self.ready.try_reserve(1).map_err(|e| format!("NVDEC output allocation failed: {e}"))?;
self.ready.push((info.timestamp, frame));
Ok(())
}
fn readback(&self, address: u64, pitch: u32) -> Result<VideoFrame, String> {
if address == 0 {
return Err("NVDEC returned a null mapped address".into());
}
let (luma_size, total) = mapped_layout(self.coded, self.geometry.bits, pitch)?;
let picture_bytes = (self.coded.0 as usize)
.checked_mul(self.coded.1 as usize)
.and_then(|n| n.checked_mul(if self.geometry.bits == 10 { 3 } else { 2 }))
.ok_or("NVDEC output size overflows")?;
if self.ready.len() >= MAX_SURFACES as usize
|| self.ready.len().checked_add(1).and_then(|n| n.checked_mul(picture_bytes)).is_none_or(|n| n > MAX_READY_BYTES)
{
return Err("NVDEC output queue exceeds its memory limit".into());
}
address.checked_add(u64::try_from(total).map_err(|_| "NVDEC mapped size overflows")?).ok_or("NVDEC mapped address overflows")?;
let mut host = Vec::new();
host.try_reserve_exact(total).map_err(|e| format!("NVDEC readback allocation failed: {e}"))?;
host.resize(total, 0);
// SAFETY: Session's separately boxed device outlives State and every synchronous callback;
// it is immutable, and the CUDA context is current. No mutable Device references exist.
let device = unsafe { self.device.as_ref() }.ok_or("NVDEC CUDA device is missing")?;
device.copy_to_host(address, &mut host)?;
let luma = host.get(..luma_size).ok_or("NVDEC luma range is invalid")?;
let chroma = host.get(luma_size..).ok_or("NVDEC chroma range is invalid")?;
to_frame(&Biplanar { luma, chroma, stride: pitch as usize, rows: self.coded.1 as usize }, &self.geometry)
}
}
fn mapped_layout(coded: (u32, u32), bits: u32, pitch: u32) -> Result<(usize, usize), String> {
let row_bytes = coded.0.checked_mul(if bits == 10 { 2 } else { 1 }).ok_or("NVDEC row size overflows")?;
if pitch < row_bytes || pitch == 0 || coded.1 == 0 || !coded.1.is_multiple_of(2) || !matches!(bits, 8 | 10) {
return Err("NVDEC reported an invalid mapped pitch or size".into());
}
let luma = (pitch as usize).checked_mul(coded.1 as usize).ok_or("NVDEC luma size overflows")?;
let total = luma.checked_add(luma / 2).ok_or("NVDEC mapped size overflows")?;
if total > MAX_FRAME_BYTES {
return Err("NVDEC mapped frame exceeds its memory limit".into());
}
Ok((luma, total))
}
struct Mapped<'a> {
api: Api,
decoder: *mut c_void,
address: u64,
outstanding: &'a Cell<Option<u64>>,
active: bool,
}
impl Mapped<'_> {
fn unmap(mut self) -> Result<(), String> {
self.active = false;
// SAFETY: this guard owns one successful mapping on this decoder, and the context is
// still current. No GPU-memory references escape the synchronous host copy.
status(unsafe { (self.api.unmap)(self.decoder, self.address) }, "cuvidUnmapVideoFrame64")?;
self.outstanding.set(None);
Ok(())
}
}
impl Drop for Mapped<'_> {
fn drop(&mut self) {
if self.active {
// SAFETY: balances a successful map on every early return or caught panic;
// the enclosing parser operation retains the current context and driver library.
let st = unsafe { (self.api.unmap)(self.decoder, self.address) };
match status(st, "cuvidUnmapVideoFrame64") {
Ok(()) => self.outstanding.set(None),
Err(e) => log::warn!("{e}"),
}
}
}
}
/// Callback state is exclusively accessed by synchronous callbacks while parse is running.
unsafe fn callback(data: *mut c_void, f: impl FnOnce(&mut State) -> Result<i32, String>) -> i32 {
// SAFETY: cuvid receives the stable Box<State> pointer at parser creation; callbacks run
// synchronously, without a live Rust reference to State in the enclosing parse call.
let Some(state) = (unsafe { data.cast::<State>().as_mut() }) else { return 0 };
if state.error.is_some() {
return 0;
}
match catch_unwind(AssertUnwindSafe(|| f(state))) {
Ok(Ok(value)) => value,
Ok(Err(error)) => {
state.error = Some(error);
0
}
Err(_) => {
state.error = Some("NVDEC callback panicked".into());
0
}
}
}
unsafe extern "C" fn sequence(data: *mut c_void, format: *mut CUVIDEOFORMAT) -> i32 {
// SAFETY: cuvid invokes this callback with its synchronous format pointer and our boxed state.
unsafe { callback(data, |state| state.sequence(format.as_ref().ok_or("NVDEC sequence pointer is null")?)) }
}
unsafe extern "C" fn decode(data: *mut c_void, picture: *mut CUVIDPICPARAMS) -> i32 {
// SAFETY: the opaque picture parameters belong to cuvid for the callback's duration;
// they are only passed through to the live decoder with the context current.
unsafe {
callback(data, |state| {
if state.decoder.is_null() || picture.is_null() {
return Err("NVDEC decode pointer or decoder is null".into());
}
state.drain()?;
status((state.api.decode)(state.decoder, picture), "cuvidDecodePicture")?;
Ok(1)
})
}
}
unsafe extern "C" fn display(data: *mut c_void, picture: *mut CUVIDPARSERDISPINFO) -> i32 {
// SAFETY: cuvid supplies display metadata valid during this synchronous callback;
// null is only an EOS notification, which we did not request.
unsafe { callback(data, |state| state.display(picture.as_ref().ok_or("NVDEC display pointer is null")?)) }
}
/// A movable session. Each operation pushes its CUDA context on the calling thread.
pub struct Session {
state: Box<State>,
parser: *mut c_void,
device: Box<Device>,
// Dropped after device/context release, and after all cuvid handles have been destroyed.
_library: libloading::Library,
}
// SAFETY: no callbacks or GPU-memory references outlive an operation. The boxed state and device
// retain stable addresses across moves; every driver call pushes the context on its calling thread.
unsafe impl Send for Session {}
impl Session {
pub fn new(codec: NalCodec, coded: (u32, u32), geometry: Geometry, reorder: usize) -> Result<Self, String> {
let delay = validate(coded, geometry, reorder)?;
if matches!(codec, NalCodec::H264) && geometry.bits != 8 {
return Err("NVDEC only takes 8-bit H.264".into());
}
let codec = match codec {
NalCodec::H264 => cudaVideoCodec_H264,
NalCodec::Hevc => cudaVideoCodec_HEVC,
};
let (api, library) = Api::load()?;
let device = Box::new(Device::new()?);
{
let _current = device.push_current()?;
let mut capabilities = caps(codec, geometry.bits);
// SAFETY: capabilities is a live writable C struct, and the CUDA context is current.
status(unsafe { (api.caps)(&mut capabilities) }, "cuvidGetDecoderCaps")?;
check_caps(&capabilities, coded, geometry.bits)?;
}
let state = Box::new(State {
api,
device: ptr::from_ref(device.as_ref()),
decoder: ptr::null_mut(),
codec,
coded,
geometry,
delay,
surfaces: 0,
mapped: Cell::new(None),
pending: Vec::new(),
ready: Vec::new(),
error: None,
});
let mut session = Self { state, parser: ptr::null_mut(), device, _library: library };
session.create_parser()?;
Ok(session)
}
fn create_parser(&mut self) -> Result<(), String> {
let _current = self.device.push_current()?;
// SAFETY: plain C data, nullable callback pointers and opaque pointers allow zeros.
let mut params: CUVIDPARSERPARAMS = unsafe { std::mem::zeroed() };
params.CodecType = self.state.codec;
params.ulMaxNumDecodeSurfaces = 1; // sequence callback supplies the real DPB size
params.ulClockRate = 0;
params.ulMaxDisplayDelay = self.state.delay;
params.pUserData = (&raw mut *self.state).cast();
params.pfnSequenceCallback = Some(sequence);
params.pfnDecodePicture = Some(decode);
params.pfnDisplayPicture = Some(display);
// SAFETY: params and output pointer are valid; boxed user data remains alive until
// parser destruction, and the library is retained by Session.
status(unsafe { (self.state.api.create_parser)(&mut self.parser, &mut params) }, "cuvidCreateVideoParser")?;
if self.parser.is_null() {
return Err("NVDEC returned a null parser".into());
}
Ok(())
}
fn parse(&mut self, payload: &[u8], pts: i64, flags: u64) -> Result<Vec<(i64, VideoFrame)>, String> {
if let Some(error) = &self.state.error {
return Err(error.clone());
}
if self.parser.is_null() {
return Err("NVDEC parser is not available".into());
}
let _current = self.device.push_current()?;
let mut packet = CUVIDSOURCEDATAPACKET {
flags,
payload_size: u64::try_from(payload.len()).map_err(|_| "NVDEC packet length overflows")?,
payload: if payload.is_empty() { ptr::null() } else { payload.as_ptr() },
timestamp: pts,
};
// SAFETY: parser is live, payload and packet outlive this synchronous call. We do not
// borrow State across it, so callbacks can access their exclusive boxed user data.
let st = unsafe { (self.state.api.parse)(self.parser, &mut packet) };
if let Some(error) = &self.state.error {
return Err(error.clone());
}
status(st, "cuvidParseVideoData")?;
match catch_unwind(AssertUnwindSafe(|| self.state.drain())) {
Ok(result) => result?,
Err(_) => return Err("NVDEC readback panicked".into()),
}
Ok(std::mem::take(&mut self.state.ready))
}
pub fn feed(&mut self, annex_b: &[u8], pts: i64) -> Result<Vec<(i64, VideoFrame)>, String> {
if annex_b.is_empty() || annex_b.len() > MAX_PACKET_BYTES {
return Err("NVDEC access unit is empty or too large".into());
}
let result = self.parse(annex_b, pts, CUVID_PKT_TIMESTAMP | CUVID_PKT_ENDOFPICTURE);
if let Err(error) = &result {
self.state.error.get_or_insert_with(|| error.clone());
}
result
}
pub fn flush(&mut self) -> Result<Vec<(i64, VideoFrame)>, String> {
let result = self.parse(&[], 0, CUVID_PKT_ENDOFSTREAM);
self.restart()?;
result
}
/// Destroy the parser and, unless `keep_decoder`, the decoder (a seek keeps it: creating one
/// costs tens of milliseconds, and the next parser's sequence callback takes it as it is).
fn destroy(&mut self, keep_decoder: bool) -> Result<(), String> {
let _current = self.device.push_current()?;
let mut error = None;
if let Some(address) = self.state.mapped.get() {
// SAFETY: retry an outstanding map before releasing its decoder; context is current.
match status(unsafe { (self.state.api.unmap)(self.state.decoder, address) }, "cuvidUnmapVideoFrame64") {
Ok(()) => self.state.mapped.set(None),
Err(e) => {
error = Some(e);
}
}
}
if !keep_decoder && !self.state.decoder.is_null() {
// SAFETY: owned decoder, no host views or callbacks remain; unmapping was attempted
// first, and context and library remain live while the driver releases its resources.
match status(unsafe { (self.state.api.destroy_decoder)(self.state.decoder) }, "cuvidDestroyDecoder") {
Ok(()) => {
self.state.decoder = ptr::null_mut();
self.state.surfaces = 0;
self.state.mapped.set(None);
}
Err(e) => {
error.get_or_insert(e);
}
}
}
if !self.parser.is_null() {
// SAFETY: owned parser, no parse call running; user data and library are still live.
match status(unsafe { (self.state.api.destroy_parser)(self.parser) }, "cuvidDestroyVideoParser") {
Ok(()) => self.parser = ptr::null_mut(),
Err(e) => {
error.get_or_insert(e);
}
}
}
match error {
Some(error) => Err(error),
None => Ok(()),
}
}
fn restart(&mut self) -> Result<(), String> {
self.state.ready.clear();
self.state.pending.clear();
self.state.error = None;
let result = self.destroy(true).and_then(|()| self.create_parser());
if let Err(error) = &result {
self.state.error = Some(error.clone());
}
result
}
pub fn reset(&mut self) {
if let Err(e) = self.restart() {
log::warn!("NVDEC reset failed: {e}");
}
}
}
impl Drop for Session {
fn drop(&mut self) {
if let Err(e) = self.destroy(false) {
log::warn!("NVDEC cleanup failed: {e}");
}
}
}
/// Load both drivers and retain a primary context; does not create a decoder or parse video.
pub fn probe() -> Result<String, String> {
let (_api, _library) = Api::load()?;
let device = Device::new()?;
let _current = device.push_current()?;
Ok("NVDEC (libnvcuvid, first CUDA device)".into())
}
#[cfg(test)]
mod tests {
use super::*;
fn geometry() -> Geometry {
Geometry { crop: (0, 0, 640, 360), bits: 8, color: filmcraft_color::ColorInfo::REC709, par: (1, 1) }
}
#[test]
fn hostile_geometry_and_driver_sizes_are_errors() {
for size in [(0, 0), (u32::MAX, 368), (640, u32::MAX), (641, 368)] {
assert!(catch_unwind(|| validate(size, geometry(), 2)).unwrap().is_err());
}
let mut g = geometry();
g.crop = (u32::MAX, 0, 640, 360);
assert!(validate((640, 368), g, 2).is_err());
assert!(validate((640, 368), geometry(), usize::MAX).is_err());
assert_eq!(validate((640, 368), geometry(), 2).unwrap(), 2);
for (size, bits, pitch) in [((640, 368), 8, 639), ((640, 368), 10, 1279), ((640, 368), 8, u32::MAX), ((640, u32::MAX), 10, 1280)] {
assert!(catch_unwind(|| mapped_layout(size, bits, pitch)).unwrap().is_err());
}
assert_eq!(mapped_layout((640, 368), 8, 640).unwrap(), (235520, 353280));
assert_eq!(mapped_layout((640, 368), 10, 1280).unwrap(), (471040, 706560));
}
#[test]
fn capabilities_decline_unsupported_streams() {
let mut c = caps(cudaVideoCodec_HEVC, 10);
assert!(check_caps(&c, (640, 368), 10).is_err());
c.bIsSupported = 1;
c.nOutputFormatMask = 2;
c.nMaxWidth = 8192;
c.nMaxHeight = 8192;
c.nMaxMBCount = 65536;
assert!(check_caps(&c, (640, 368), 10).is_ok());
assert!(check_caps(&c, (640, 368), 8).is_err());
c.nMaxMBCount = 1;
assert!(check_caps(&c, (640, 368), 10).is_err());
c.nMinWidth = 1000;
assert!(check_caps(&c, (640, 368), 10).is_err());
}
#[test]
fn session_is_send() {
fn is_send<T: Send>() {}
is_send::<Session>();
}
// These stubs must never be called: the callback tests stop before driver access.
unsafe extern "C" fn test_caps(_: *mut CUVIDDECODECAPS) -> i32 {
999
}
unsafe extern "C" fn test_create_decoder(_: *mut *mut c_void, _: *mut CUVIDDECODECREATEINFO) -> i32 {
999
}
unsafe extern "C" fn test_destroy(_: *mut c_void) -> i32 {
999
}
unsafe extern "C" fn test_decode(_: *mut c_void, _: *mut CUVIDPICPARAMS) -> i32 {
999
}
unsafe extern "C" fn test_map(_: *mut c_void, _: i32, _: *mut u64, _: *mut u32, _: *mut CUVIDPROCPARAMS) -> i32 {
999
}
unsafe extern "C" fn test_unmap(_: *mut c_void, _: u64) -> i32 {
999
}
unsafe extern "C" fn test_create_parser(_: *mut *mut c_void, _: *mut CUVIDPARSERPARAMS) -> i32 {
999
}
unsafe extern "C" fn test_parse(_: *mut c_void, _: *mut CUVIDSOURCEDATAPACKET) -> i32 {
999
}
fn test_state() -> State {
State {
api: Api {
caps: test_caps,
create_decoder: test_create_decoder,
destroy_decoder: test_destroy,
decode: test_decode,
map: test_map,
unmap: test_unmap,
create_parser: test_create_parser,
parse: test_parse,
destroy_parser: test_destroy,
},
device: ptr::null(),
decoder: ptr::null_mut(),
codec: cudaVideoCodec_H264,
coded: (640, 368),
geometry: geometry(),
delay: 2,
surfaces: 0,
mapped: Cell::new(None),
pending: Vec::new(),
ready: Vec::new(),
error: None,
}
}
#[test]
fn callback_panics_and_hostile_metadata_do_not_escape() {
let mut state = test_state();
let data = ptr::from_mut(&mut state).cast();
// SAFETY: data points to exclusive live test state; the closure touches no driver.
assert_eq!(unsafe { callback(data, |_| panic!("synthetic callback panic")) }, 0);
assert_eq!(state.error.as_deref(), Some("NVDEC callback panicked"));
// SAFETY: same live state. A recorded failure prevents the second closure running.
assert_eq!(unsafe { callback(data, |_| Err("later error".into())) }, 0);
assert_eq!(state.error.as_deref(), Some("NVDEC callback panicked"));
state.error = None;
let data = ptr::from_mut(&mut state).cast();
// SAFETY: valid test user data; null metadata is deliberately tested and never dereferenced.
assert_eq!(unsafe { sequence(data, ptr::null_mut()) }, 0);
assert_eq!(state.error.as_deref(), Some("NVDEC sequence pointer is null"));
// SAFETY: C may report null user data; our callback rejects it before dereferencing.
assert_eq!(unsafe { display(ptr::null_mut(), ptr::null_mut()) }, 0);
// SAFETY: POD C metadata can be initialized to zeros for these hostile-data tests.
let mut format: CUVIDEOFORMAT = unsafe { std::mem::zeroed() };
format.codec = state.codec;
format.coded_width = 640;
format.coded_height = 368;
format.chroma_format = cudaVideoChromaFormat_420;
format.progressive_sequence = 1;
for count in [0, 65, u8::MAX] {
format.min_num_decode_surfaces = count;
assert!(state.sequence(&format).is_err());
}
// SAFETY: POD display metadata; the null decoder and hostile index fail before mapping.
let mut picture: CUVIDPARSERDISPINFO = unsafe { std::mem::zeroed() };
state.decoder = ptr::dangling_mut::<c_void>();
state.surfaces = 4;
picture.progressive_frame = 1;
for index in [-1, 4, i32::MAX] {
picture.picture_index = index;
assert!(state.display(&picture).is_err());
}
picture.picture_index = 0;
picture.timestamp = 17;
assert_eq!(state.display(&picture).unwrap(), 1);
picture.picture_index = 1;
picture.timestamp = -9;
assert_eq!(state.display(&picture).unwrap(), 1);
assert_eq!(state.pending.iter().map(|p| (p.picture_index, p.timestamp)).collect::<Vec<_>>(), vec![(0, 17), (1, -9)]);
}
unsafe extern "C" fn test_unmap_ok(_: *mut c_void, _: u64) -> i32 {
0
}
#[test]
fn mapping_guard_tracks_cleanup_on_success_failure_and_unwind() {
let outstanding = Cell::new(Some(123));
let mut api = test_state().api;
api.unmap = test_unmap_ok;
drop(Mapped { api, decoder: ptr::null_mut(), address: 123, outstanding: &outstanding, active: true });
assert_eq!(outstanding.get(), None);
outstanding.set(Some(123));
assert!(
catch_unwind(AssertUnwindSafe(|| {
let _mapped = Mapped { api, decoder: ptr::null_mut(), address: 123, outstanding: &outstanding, active: true };
panic!("synthetic readback panic");
}))
.is_err()
);
assert_eq!(outstanding.get(), None);
outstanding.set(Some(123));
api.unmap = test_unmap;
assert!(Mapped { api, decoder: ptr::null_mut(), address: 123, outstanding: &outstanding, active: true }.unmap().is_err());
assert_eq!(outstanding.get(), Some(123));
}
}

View File

@ -0,0 +1,213 @@
//
// Copyright (c) 2010-2026 NVIDIA Corporation
//
// Permission is hereby granted, free of charge, to any person
// obtaining a copy of this software and associated documentation
// files (the "Software"), to deal in the Software without
// restriction, including without limitation the rights to use,
// copy, modify, merge, publish, distribute, sublicense, and/or sell
// copies of the software, and to permit persons to whom the
// software is furnished to do so, subject to the following
// conditions:
//
// The above copyright notice and this permission notice shall be
// included in all copies or substantial portions of the Software.
//
// THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND,
// EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES
// OF MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND
// NONINFRINGEMENT. IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT
// HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY,
// WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING
// FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR
// OTHER DEALINGS IN THE SOFTWARE.
//
//! NVDEC declarations transcribed from NVIDIA's MIT headers (NVDEC API 13.1).
//! Linux LP64 only; C `tcu_ulong` is an unsigned long, not a u32.
#![allow(non_snake_case, non_camel_case_types, non_upper_case_globals, dead_code)]
use std::ffi::{c_int, c_ulong, c_void};
pub const cudaVideoCodec_H264: c_int = 4;
pub const cudaVideoCodec_HEVC: c_int = 8;
pub const cudaVideoChromaFormat_420: c_int = 1;
pub const cudaVideoSurfaceFormat_NV12: c_int = 0;
pub const cudaVideoSurfaceFormat_P016: c_int = 1;
pub const cudaVideoDeinterlaceMode_Weave: c_int = 0;
pub const cudaVideoCreate_PreferCUVID: c_ulong = 4;
pub const CUVID_PKT_ENDOFSTREAM: c_ulong = 1;
pub const CUVID_PKT_TIMESTAMP: c_ulong = 2;
pub const CUVID_PKT_ENDOFPICTURE: c_ulong = 8;
#[repr(C)]
pub struct CUVIDPICPARAMS {
_opaque: [u8; 0],
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct FrameRate {
pub numerator: u32,
pub denominator: u32,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct Rect {
pub left: i32,
pub top: i32,
pub right: i32,
pub bottom: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct ShortRect {
pub left: i16,
pub top: i16,
pub right: i16,
pub bottom: i16,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct AspectRatio {
pub x: i32,
pub y: i32,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct VideoSignal {
pub flags: u8,
pub color_primaries: u8,
pub transfer_characteristics: u8,
pub matrix_coefficients: u8,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CUVIDEOFORMAT {
pub codec: c_int,
pub frame_rate: FrameRate,
pub progressive_sequence: u8,
pub bit_depth_luma_minus8: u8,
pub bit_depth_chroma_minus8: u8,
pub min_num_decode_surfaces: u8,
pub coded_width: u32,
pub coded_height: u32,
pub display_area: Rect,
pub chroma_format: c_int,
pub bitrate: u32,
pub display_aspect_ratio: AspectRatio,
pub video_signal_description: VideoSignal,
pub seqhdr_data_length: u32,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CUVIDDECODECAPS {
pub eCodecType: c_int,
pub eChromaFormat: c_int,
pub nBitDepthMinus8: u32,
pub reserved1: [u32; 3],
pub bIsSupported: u8,
pub nNumNVDECs: u8,
pub nOutputFormatMask: u16,
pub nMaxWidth: u32,
pub nMaxHeight: u32,
pub nMaxMBCount: u32,
pub nMinWidth: u16,
pub nMinHeight: u16,
pub bIsHistogramSupported: u8,
pub nCounterBitDepth: u8,
pub nMaxHistogramBins: u16,
pub bIsDecodeStatsSupported: u8,
pub reserved4: [u8; 3],
pub reserved3: [u32; 9],
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CUVIDDECODECREATEINFO {
pub ulWidth: c_ulong,
pub ulHeight: c_ulong,
pub ulNumDecodeSurfaces: c_ulong,
pub CodecType: c_int,
pub ChromaFormat: c_int,
pub ulCreationFlags: c_ulong,
pub bitDepthMinus8: c_ulong,
pub ulIntraDecodeOnly: c_ulong,
pub ulMaxWidth: c_ulong,
pub ulMaxHeight: c_ulong,
pub Reserved1: c_ulong,
pub display_area: ShortRect,
pub OutputFormat: c_int,
pub DeinterlaceMode: c_int,
pub ulTargetWidth: c_ulong,
pub ulTargetHeight: c_ulong,
pub ulNumOutputSurfaces: c_ulong,
pub vidLock: *mut c_void,
pub target_rect: ShortRect,
pub enableHistogram: c_ulong,
pub enableDecodeFeatures: c_ulong,
pub Reserved2: [c_ulong; 3],
}
pub type SequenceCallback = unsafe extern "C" fn(*mut c_void, *mut CUVIDEOFORMAT) -> c_int;
pub type DecodeCallback = unsafe extern "C" fn(*mut c_void, *mut CUVIDPICPARAMS) -> c_int;
pub type DisplayCallback = unsafe extern "C" fn(*mut c_void, *mut CUVIDPARSERDISPINFO) -> c_int;
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CUVIDPARSERPARAMS {
pub CodecType: c_int,
pub ulMaxNumDecodeSurfaces: u32,
pub ulClockRate: u32,
pub ulErrorThreshold: u32,
pub ulMaxDisplayDelay: u32,
pub flags: u32,
pub uReserved1: [u32; 4],
pub pUserData: *mut c_void,
pub pfnSequenceCallback: Option<SequenceCallback>,
pub pfnDecodePicture: Option<DecodeCallback>,
pub pfnDisplayPicture: Option<DisplayCallback>,
pub pfnGetOperatingPoint: Option<unsafe extern "C" fn(*mut c_void, *mut c_void) -> c_int>,
pub pfnGetSEIMsg: Option<unsafe extern "C" fn(*mut c_void, *mut c_void) -> c_int>,
pub pvReserved2: [*mut c_void; 5],
pub pExtVideoInfo: *mut c_void,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CUVIDSOURCEDATAPACKET {
pub flags: c_ulong,
pub payload_size: c_ulong,
pub payload: *const u8,
pub timestamp: i64,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CUVIDPARSERDISPINFO {
pub picture_index: c_int,
pub progressive_frame: c_int,
pub top_field_first: c_int,
pub repeat_first_field: c_int,
pub timestamp: i64,
}
#[repr(C)]
#[derive(Clone, Copy)]
pub struct CUVIDPROCPARAMS {
pub progressive_frame: c_int,
pub second_field: c_int,
pub top_field_first: c_int,
pub unpaired_field: c_int,
pub reserved_flags: u32,
pub reserved_zero: u32,
pub raw_input_dptr: u64,
pub raw_input_pitch: u32,
pub raw_input_format: u32,
pub raw_output_dptr: u64,
pub raw_output_pitch: u32,
pub Reserved1: u32,
pub output_stream: *mut c_void,
pub Reserved: [u32; 46],
pub histogram_dptr: *mut u64,
pub pCuvidProcExt: *mut c_void,
}
pub type GetDecoderCaps = unsafe extern "C" fn(*mut CUVIDDECODECAPS) -> c_int;
pub type CreateDecoder = unsafe extern "C" fn(*mut *mut c_void, *mut CUVIDDECODECREATEINFO) -> c_int;
pub type DestroyDecoder = unsafe extern "C" fn(*mut c_void) -> c_int;
pub type DecodePicture = unsafe extern "C" fn(*mut c_void, *mut CUVIDPICPARAMS) -> c_int;
pub type MapVideoFrame = unsafe extern "C" fn(*mut c_void, c_int, *mut u64, *mut u32, *mut CUVIDPROCPARAMS) -> c_int;
pub type UnmapVideoFrame = unsafe extern "C" fn(*mut c_void, u64) -> c_int;
pub type CreateVideoParser = unsafe extern "C" fn(*mut *mut c_void, *mut CUVIDPARSERPARAMS) -> c_int;
pub type ParseVideoData = unsafe extern "C" fn(*mut c_void, *mut CUVIDSOURCEDATAPACKET) -> c_int;
pub type DestroyVideoParser = unsafe extern "C" fn(*mut c_void) -> c_int;

View File

@ -0,0 +1,123 @@
//! Hardware decoding through NVDEC (Linux, NVIDIA's proprietary driver).
//!
//! Unlike VA-API, NVDEC brings its own stream parser (`libnvcuvid`): [`cuvid::Session`] is fed
//! Annex B access units and returns pictures in presentation order, read back from the GPU through
//! the CUDA driver. Both libraries are loaded at run time; without them there is no NVDEC decoder,
//! never a failure to start. H.264 8-bit and HEVC 8- / 10-bit, 4:2:0 progressive; everything else
//! stays with VA-API or in software.
//!
//! The FFI is in [`ffi`] (declarations) and [`cuvid`] (calls); the rest of this module is safe code.
#[allow(unsafe_code)]
pub mod cuvid;
pub mod ffi;
#[cfg(test)]
mod abi_tests;
use filmcraft_codecs::hw::{NalCodec, NalStreamInfo};
use filmcraft_codecs::{CodecError, DecodedFrame, Result, VideoDecoder};
use crate::annexb::to_annex_b;
use crate::biplanar::Geometry;
/// An NVDEC decoder for one H.264 or HEVC stream.
pub struct NvDecoder {
session: cuvid::Session,
info: NalStreamInfo,
name: &'static str,
/// Whether the parser has the stream's parameter sets (sent with the first sample after a reset).
primed: bool,
/// HEVC, after starting at a CRA / BLA picture: its RASL pictures (they refer to pictures
/// before it) are not fed until the next random access point.
skip_rasl: bool,
fed: u64,
fail_after: Option<u64>,
}
impl NvDecoder {
/// A decoder for the stream `info` describes, or why NVDEC does not take it on this system.
pub fn new(info: NalStreamInfo) -> std::result::Result<Self, String> {
let bits = info.bit_depth_luma;
let taken = match info.codec {
NalCodec::H264 => bits == 8,
NalCodec::Hevc => matches!(bits, 8 | 10),
};
if !taken || info.chroma_format_idc != 1 || info.bit_depth_chroma != bits || info.interlaced {
return Err(format!(
"{} chroma format {} at {bits}/{}-bit is not taken",
if info.interlaced { "interlaced" } else { "progressive" },
info.chroma_format_idc,
info.bit_depth_chroma
));
}
let geometry = Geometry { crop: info.crop, bits, color: info.color, par: info.par };
let session = cuvid::Session::new(info.codec, info.coded, geometry, info.reorder)?;
let name = match info.codec {
NalCodec::H264 => "NVDEC H.264",
NalCodec::Hevc => "NVDEC HEVC",
};
Ok(Self { session, info, name, primed: false, skip_rasl: false, fed: 0, fail_after: None })
}
/// Test hook: fail every decode call after the first `n` (a GPU lost mid-stream).
pub fn fail_after(&mut self, n: u64) {
self.fail_after = Some(n);
}
}
fn frames(out: Vec<(i64, filmcraft_frame::VideoFrame)>) -> Vec<DecodedFrame> {
out.into_iter().map(|(pts, frame)| DecodedFrame { pts, frame, draft: false }).collect()
}
impl VideoDecoder for NvDecoder {
fn decode(&mut self, sample: &[u8], pts: i64) -> Result<Vec<DecodedFrame>> {
self.fed += 1;
if self.fail_after.is_some_and(|n| self.fed > n) {
return Err(CodecError::Decode("NVDEC decoder failed (test hook)".into()));
}
if self.info.codec == NalCodec::Hevc {
let types = self.info.nal_types(sample);
if self.info.is_irap(sample) {
// IDR pictures (19, 20) have no RASL pictures
self.skip_rasl = !self.primed && !types.iter().any(|t| matches!(t, 19 | 20));
} else if self.skip_rasl && types.iter().any(|t| matches!(t, 8 | 9)) {
// Not output by any decoder (the software one drops them too); the parser drops
// them as well, but then shows the CRA picture with the last one's timestamp.
return Ok(Vec::new());
}
}
let data = to_annex_b(&self.info, sample, !self.primed).map_err(CodecError::Decode)?;
self.primed = true;
self.session.feed(&data, pts).map(frames).map_err(CodecError::Decode)
}
fn flush(&mut self) -> Vec<DecodedFrame> {
// the parser starts over after an end of stream
self.primed = false;
match self.session.flush() {
Ok(out) => frames(out),
Err(e) => {
log::warn!("{}: {e} while flushing", self.name);
Vec::new()
}
}
}
fn reset(&mut self) {
self.primed = false;
self.session.reset();
}
fn name(&self) -> &str {
self.name
}
fn is_random_access(&self, sample: &[u8]) -> Option<bool> {
self.info.is_random_access(sample)
}
fn is_disposable(&self, sample: &[u8]) -> bool {
self.info.is_disposable(sample)
}
}

View File

@ -67,10 +67,22 @@ mod directx {
#[cfg(target_os = "linux")]
mod cuda {
use super::c_void;
use std::marker::PhantomData;
use std::rc::Rc;
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;
type Push = unsafe extern "C" fn(*mut c_void) -> i32;
type Pop = unsafe extern "C" fn(*mut *mut c_void) -> i32;
type CopyToHost = unsafe extern "C" fn(*mut c_void, u64, usize) -> i32;
/// A thread-local context stack entry; it cannot move to another thread.
pub struct Current<'a> {
_device: &'a Device,
pop: Pop,
_thread: PhantomData<Rc<()>>,
}
/// 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.
@ -116,6 +128,49 @@ mod cuda {
pub fn as_raw(&self) -> *mut c_void {
self.context
}
pub fn push_current(&self) -> Result<Current<'_>, String> {
// SAFETY: signatures match the CUDA driver headers; the retained context and
// library outlive the guard, whose Drop balances the push on this same thread.
unsafe {
let push: Push = *self._library.get(b"cuCtxPushCurrent_v2\0").map_err(|e| e.to_string())?;
let pop: Pop = *self._library.get(b"cuCtxPopCurrent_v2\0").map_err(|e| e.to_string())?;
let st = push(self.context);
if st != 0 {
return Err(format!("cuCtxPushCurrent failed ({st})"));
}
Ok(Current { _device: self, pop, _thread: PhantomData })
}
}
/// Copy a driver-owned device address into a bounded host slice. CUDA validates
/// the source device range; the destination is always a live writable Rust slice.
pub fn copy_to_host(&self, source: u64, destination: &mut [u8]) -> Result<(), String> {
let _current = self.push_current()?;
source.checked_add(u64::try_from(destination.len()).map_err(|_| "CUDA copy length overflows")?).ok_or("CUDA copy address overflows")?;
// SAFETY: the synchronous copy writes at most destination.len() bytes to its
// live buffer. The CUDA context and library remain live through the call.
let st = unsafe {
let copy: CopyToHost = *self._library.get(b"cuMemcpyDtoH_v2\0").map_err(|e| e.to_string())?;
copy(destination.as_mut_ptr().cast(), source, destination.len())
};
if st != 0 {
return Err(format!("cuMemcpyDtoH failed ({st})"));
}
Ok(())
}
}
impl Drop for Current<'_> {
fn drop(&mut self) {
let mut previous = std::ptr::null_mut();
// SAFETY: this guard owns one successful push on this thread; previous is
// a valid out-pointer and the library is retained by the borrowed device.
let st = unsafe { (self.pop)(&mut previous) };
if st != 0 {
log::warn!("cuCtxPopCurrent failed ({st})");
}
}
}
impl Drop for Device {
@ -123,7 +178,10 @@ mod cuda {
// 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);
let st = (self.release)(self.ordinal);
if st != 0 {
log::warn!("cuDevicePrimaryCtxRelease failed ({st})");
}
}
}
}

View File

@ -26,7 +26,7 @@
//! software encoder takes over. A declined HEVC export is an error that says why.
#[allow(unsafe_code)]
mod device;
pub(crate) mod device;
pub mod export;
#[allow(unsafe_code)]
mod ffi;

View File

@ -0,0 +1,182 @@
//! NVDEC decoding against our software decoders (Linux, NVIDIA's driver): every picture of the
//! shared H.264 High, HEVC Main (open GOP) and Main 10 fixtures and of 1080p streams must be
//! identical (bit-exact planes, colour, pixel aspect, pts and presentation order), also after
//! `reset` + reseek; a forced mid-stream failure must continue in software with the software
//! decoder's exact output; what the hardware path does not take is declined; damaged samples give
//! errors or fall back, never crash or hang. Skips without ffmpeg (fixture generator) or without
//! an NVIDIA GPU.
#![cfg(all(target_os = "linux", target_pointer_width = "64"))]
mod common;
use std::time::Duration;
use common::*;
use filmcraft_codecs::VideoDecoder;
use filmcraft_codecs::hw::{NalCodec, NalStreamInfo};
use filmcraft_platform::HybridDecoder;
use filmcraft_platform::nvdec::NvDecoder;
fn hardware(s: &Stream) -> Option<Box<dyn VideoDecoder>> {
match filmcraft_platform::nvdec_factory(&s.entry) {
Some(Ok(d)) => Some(d),
Some(Err(e)) => panic!("factory error: {e}"),
None => {
eprintln!("SKIPPED: no NVDEC hardware decoder for {}", s.entry.codec.name());
None
}
}
}
fn software(s: &Stream) -> Box<dyn VideoDecoder> {
filmcraft_codecs::software_video_decoder(&s.entry).unwrap()
}
fn streams(ff: &std::path::Path) -> Vec<(String, Stream)> {
let mut out: Vec<_> =
["h264_high.mp4", "hevc_main.mp4", "hevc_main10.mp4"].iter().filter_map(|n| Some((n.to_string(), read_stream(&named(ff, n)?)))).collect();
let src = "testsrc2=s=1920x1080:r=24:d=2,noise=alls=12:allf=t";
let gop = "bframes=3:b-pyramid=normal:keyint=24:min-keyint=24:scenecut=0:ref=4";
let x265 = format!("log-level=error:{}", gop.replace("normal", "1"));
let cases: [(&str, Vec<&str>); 2] = [
("nv_h264_1080.mp4", vec!["-c:v", "libx264", "-profile:v", "high", "-preset", "veryfast", "-x264-params", gop, "-pix_fmt", "yuv420p"]),
("nv_hevc_main10_1080.mp4", vec!["-c:v", "libx265", "-preset", "ultrafast", "-tag:v", "hvc1", "-x265-params", &x265, "-pix_fmt", "yuv420p10le"]),
];
for (name, enc) in cases {
let args: Vec<&str> = ["-f", "lavfi", "-i", src].into_iter().chain(enc).collect();
if let Some(path) = fixture(ff, name, &args) {
out.push((name.to_string(), read_stream(&path)));
}
}
out
}
#[test]
fn bit_exact_with_the_software_decoder() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (name, s) in streams(&ff) {
let Some(mut hw) = hardware(&s) else { continue };
let mut sw = software(&s);
let a = decode_all(hw.as_mut(), &s.samples);
assert!(a.len() >= 40, "{name}: {} pictures", a.len());
assert_same(&name, &a, &decode_all(sw.as_mut(), &s.samples));
// reset + reseek to each later sync sample. After a seek to a non-IDR H.264 I picture
// (open GOP) the pictures shown before it are concealed differently: only those from the
// seek point on must match. (HEVC drops such pictures, RASL, in both decoders.)
let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap();
for k in (1..s.samples.len()).filter(|&i| s.sync[i]).rev() {
let end = (k + 17).min(s.samples.len());
hw.reset();
sw.reset();
let mut a = decode_all(hw.as_mut(), &s.samples[k..end]);
let mut b = decode_all(sw.as_mut(), &s.samples[k..end]);
if info.codec == NalCodec::H264 && !info.nal_types(&s.samples[k].0).contains(&5) {
let start = s.samples[k].1;
a.retain(|f| f.pts >= start);
b.retain(|f| f.pts >= start);
}
assert!(!a.is_empty(), "{name}: pictures after seeking to {k}");
assert_same(&format!("{name} from sample {k}"), &a, &b);
}
hw.reset();
sw.reset();
assert_same(&format!("{name} after resets"), &decode_all(hw.as_mut(), &s.samples), &decode_all(sw.as_mut(), &s.samples));
assert!(hw.name().starts_with("NVDEC"), "{name}: stayed in hardware ({})", hw.name());
}
}
#[test]
fn mid_stream_failure_continues_in_software() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (name, s) in streams(&ff) {
let info = NalStreamInfo::from_entry(&s.entry).unwrap().unwrap();
if NvDecoder::new(info.clone()).is_err() {
eprintln!("SKIPPED: no NVDEC hardware decoder for {name}");
continue;
}
let reference = decode_all(software(&s).as_mut(), &s.samples);
let k = (1..s.samples.len()).find(|&i| s.sync[i]).unwrap();
for fail_at in [0, 5, k, k + 1] {
let mut nv = NvDecoder::new(info.clone()).unwrap();
nv.fail_after(fail_at as u64);
let mut d = HybridDecoder::new(Box::new(nv), s.entry.clone(), info.clone());
let out = decode_all(&mut d, &s.samples);
assert!(!d.is_hardware(), "{name}: switched to software");
assert_same(&format!("{name} failing at sample {fail_at}"), &out, &reference);
}
}
}
#[test]
fn declines_what_the_hardware_path_does_not_take() {
let ff = filmcraft_testkit::require_ffmpeg!();
let src = "testsrc2=s=640x360:r=24:d=1";
let cases: [(&str, Vec<&str>); 3] = [
("va_h264_interlaced.mp4", vec!["-c:v", "libx264", "-x264-params", "interlaced=1:keyint=12", "-pix_fmt", "yuv420p"]),
("va_h264_10bit.mp4", vec!["-c:v", "libx264", "-profile:v", "high10", "-pix_fmt", "yuv420p10le"]),
("va_hevc_422.mp4", vec!["-c:v", "libx265", "-tag:v", "hvc1", "-x265-params", "log-level=error:keyint=12", "-pix_fmt", "yuv422p10le"]),
];
for (name, enc) in cases {
let args: Vec<&str> = ["-f", "lavfi", "-i", src].into_iter().chain(enc).collect();
let Some(path) = fixture(&ff, name, &args) else { continue };
assert!(filmcraft_platform::nvdec_factory(&read_stream(&path).entry).is_none(), "{name}: declined");
}
let Some(path) = named(&ff, "h264_high.mp4") else { return };
let s = read_stream(&path);
filmcraft_codecs::hw::set_hardware_decoding(false);
let off = filmcraft_platform::nvdec_factory(&s.entry).is_none();
filmcraft_codecs::hw::set_hardware_decoding(true);
assert!(off, "declined while hardware decoding is Off");
}
#[test]
fn damaged_samples_never_crash_or_hang() {
let ff = filmcraft_testkit::require_ffmpeg!();
for (name, s) in streams(&ff).into_iter().take(3) {
if hardware(&s).is_none() {
continue;
}
for seed in 1..=8u64 {
let (samples, entry) = (s.samples.clone(), s.entry.clone());
let (tx, rx) = std::sync::mpsc::channel();
std::thread::spawn(move || {
let r = std::panic::catch_unwind(move || {
let mut seed = seed.wrapping_mul(0x9E37_79B9_7F4A_7C15) | 1;
// through the hybrid, and straight into the hardware decoder (no fallback)
let mut d = filmcraft_platform::nvdec_factory(&entry).unwrap().unwrap();
let mut raw = NvDecoder::new(NalStreamInfo::from_entry(&entry).unwrap().unwrap()).unwrap();
for (i, (smp, pts)) in samples.iter().enumerate().take(40) {
let mut m = smp.clone();
match xorshift(&mut seed) % 4 {
0 => {
for _ in 0..1 + xorshift(&mut seed) % 8 {
let at = (xorshift(&mut seed) as usize) % m.len().max(1);
if let Some(b) = m.get_mut(at) {
*b ^= 1 << (xorshift(&mut seed) % 8);
}
}
}
1 => m.truncate((xorshift(&mut seed) as usize) % m.len().max(1)),
2 if m.len() > 4 => m[..4].copy_from_slice(&(xorshift(&mut seed) as u32).to_be_bytes()),
_ => {}
}
let _ = d.decode(&m, *pts);
let _ = raw.decode(&m, *pts);
if i % 13 == 12 {
d.reset();
raw.reset();
}
}
let _ = d.flush();
let _ = raw.flush();
});
let _ = tx.send(r.is_ok());
});
match rx.recv_timeout(Duration::from_secs(60)) {
Ok(true) => {}
Ok(false) => panic!("{name} seed {seed}: panicked"),
Err(_) => panic!("{name} seed {seed}: hung"),
}
}
}
}

View File

@ -36,8 +36,12 @@ fn off_uses_the_software_decoder() {
assert_eq!(d.name(), "FilmCraft HEVC");
return;
}
let (backend, hw_name) = if cfg!(target_os = "macos") { ("VideoToolbox", "VideoToolbox HEVC") } else { ("Media Foundation", "Media Foundation HEVC") };
assert_eq!(d.name(), hw_name);
// Linux has two backends (NVDEC in front of VA-API): the one `register` reported
let backend = match available {
filmcraft_platform::Availability::Available(name) => name,
filmcraft_platform::Availability::Unavailable(why) => panic!("a hardware decoder, but register() said {why}"),
};
assert!(d.name().starts_with(backend) && d.name().contains("HEVC"), "{} from {backend}", d.name());
assert_eq!(filmcraft_codecs::hw::hw_backend(), Some(backend), "perf.stats reports the backend");
assert!(filmcraft_platform::registered(), "register() put the factory in the registry");
let h1 = filmcraft_codecs::hw::hw_stats();

View File

@ -1808,7 +1808,7 @@
Hard Limiter Limitador duro
Hard Mix Mezcla definida
Hardware decoding Descodificación por hardware
Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Descodificación por hardware: Automático usa el descodificador de video del sistema (VideoToolbox en macOS, Media Foundation en Windows, VA-API para H.264 y HEVC en Linux) para los flujos que admite, y el descodificador propio de FilmCraft para todo lo demás o si falla el hardware. Los medios ya abiertos mantienen su descodificador hasta que se vuelven a abrir.
Hardware decoding: Auto uses the system's video decoder (VideoToolbox on macOS, Media Foundation on Windows, VA-API or NVDEC for H.264 and HEVC on Linux) for the streams it supports, and FilmCraft's own decoder for everything else or if the hardware fails. Media that is already open keeps its decoder until it is reopened. Descodificación por hardware: Automático usa el descodificador de video del sistema (VideoToolbox en macOS, Media Foundation en Windows, VA-API o NVDEC para H.264 y HEVC en Linux) para los flujos que admite, y el descodificador propio de FilmCraft para todo lo demás o si falla el hardware. Los medios ya abiertos mantienen su descodificador hasta que se vuelven a abrir.
Head Size Tamaño de la cabeza
Heart Corazón
Height Altura

Can't render this file because it contains an unexpected character in line 3 and column 95.

View File

@ -146,3 +146,14 @@ calling thread's stack. The retained reference is released after the NVENC sessi
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.
## Addendum (2026-10-09): NVDEC hardware decoding on Linux
NVIDIA's proprietary driver has no VA-API of its own (only through the separate
`libva-nvidia-driver`), so Linux gets a second decoder backend, `nvdec/`, registered in front of
VA-API's. The rules above hold: `libnvcuvid.so.1` and `libcuda.so.1` are loaded at run time,
`nvdec/ffi.rs` is transcribed from NVIDIA's MIT-licensed headers and checked by
`nvdec/abi_tests.rs`, only `nvdec::cuvid` carries `#[allow(unsafe_code)]`, and the CUDA context is
the one `nvenc::device` already retains for encoding. Unlike VA-API, NVDEC parses the stream
itself; its callbacks run inside our parse call and never unwind into C (they record the first
error and return 0, and the decoder then fails over to software like any other backend).