//! Hardware H.264 / H.265 / AV1 decode via NVIDIA NVDEC (`moq-nvenc`'s cuvid table). //! //! Linux only, behind the default-on `nvidia` feature. Everything is dlopen'd at //! runtime (cudarc loads libcuda, the cuvid table loads libnvcuvid), so the //! binary links on a GPU-less builder and a driverless host falls back to the //! next decoder (see [`backend::open`](super::open)). //! //! Decoded 8-bit 4:2:0 frames come back as NV12 in CUDA device memory //! ([`Surface::Cuda`]). //! Each mapped cuvid surface is copied device-to-device into an owned buffer //! (surfaces come from a small fixed pool, so holding them across calls would //! stall the decoder), which the NVENC encode backend then registers directly: //! the decode -> scale -> encode transcode path never touches the CPU. Scaling //! rides the decoder itself: [`Config::scale_hint`] maps to cuvid's target size, so //! the hardware emits frames already at the output resolution. //! //! The cuvid parser is driven synchronously: callbacks (sequence / decode / //! display) fire inside `cuvidParseVideoData` on the calling thread. Each access //! unit uses `CUVID_PKT_ENDOFPICTURE`, and an explicit end-of-stream packet drains //! any pictures the display queue still holds. Timestamps are threaded through //! the parser (`ulClockRate` is set to microseconds), so output frames keep //! correct presentation times even across reordering. use core::ffi::{c_int, c_uint, c_ulong, c_ulonglong, c_void}; use std::ptr; use std::sync::Arc; use bytes::Bytes; use cudarc::driver::CudaContext; use moq_net::Timestamp; use moq_nvenc::cuvid; use moq_nvenc::sys::cuviddec::{ CUVIDDECODECAPS, CUVIDDECODECREATEINFO, CUVIDPICPARAMS, CUVIDPROCPARAMS, CUvideodecoder, cudaVideoChromaFormat, cudaVideoCodec, cudaVideoCreateFlags, cudaVideoDeinterlaceMode, cudaVideoSurfaceFormat, }; use moq_nvenc::sys::nvcuvid::{ CUVIDEOFORMAT, CUVIDPARSERDISPINFO, CUVIDPARSERPARAMS, CUVIDSOURCEDATAPACKET, CUvideopacketflags, CUvideoparser, }; use super::{Backend, Codec}; use crate::frame::{Surface, cuda}; use crate::{Error, Frame}; pub(crate) const NAME: &str = "nvdec"; fn codec_err(msg: String) -> Error { Error::Codec(anyhow::anyhow!(msg)) } pub(crate) struct Nvdec { parser: CUvideoparser, /// Boxed so its address is stable: the parser holds a raw pointer to it for /// the lifetime of the parser (callbacks dereference it during parse). state: Box, /// Mark the first access unit after a drain as a new parser epoch. discontinuity: bool, } /// State shared with the parser's C callbacks via the user-data pointer. struct State { api: &'static cuvid::Api, ctx: Arc, /// Requested output size; `None` decodes at the stream's display size. resize: Option, decoder: Option, /// Pictures the display callback queued, in presentation order. Drained /// (mapped and copied out) after each parse call. ready: Vec, /// First error hit inside a callback; callbacks can only return an int, so /// the error is surfaced by `decode` after the parse call. error: Option, } /// An open cuvid decoder plus the output geometry it was created for. struct Decoder { api: &'static cuvid::Api, /// Held so the context outlives the decoder and can be bound in `Drop`: /// destroying the decoder after the last context ref is released segfaults. ctx: Arc, handle: CUvideodecoder, /// The coded size it was created for, to detect reconfigures. coded: (u32, u32), /// The display crop (left, top, right, bottom) it was created for; a crop /// change alone also requires a new decoder. display_area: (i32, i32, i32, i32), /// Output size: cuvid scales to this while writing the output surface. width: u32, height: u32, } impl Drop for Decoder { fn drop(&mut self) { // Drop may run on any thread; destroying needs the context current. if self.ctx.bind_to_thread().is_ok() { // SAFETY: the handle is valid, its context is alive and current, and // no frame is mapped (every map is paired with an unmap before decode // returns). unsafe { (self.api.destroy_decoder)(self.handle) }; } } } impl Nvdec { pub(crate) fn open(codec: Codec, config: &super::Config) -> Result, Error> { // cudarc panics when libcuda is missing, so probe it and turn a missing // driver into a recoverable error; the cuvid table already loads fallibly. if !driver_libs_present() { return Err(codec_err( "NVIDIA driver libraries not found (libcuda); NVDEC unavailable".into(), )); } let api = cuvid::Api::get().map_err(|e| codec_err(format!("NVDEC unavailable: {e}")))?; let cuda_codec = match codec { Codec::H264 => cudaVideoCodec::cudaVideoCodec_H264, Codec::H265 => cudaVideoCodec::cudaVideoCodec_HEVC, Codec::Av1 => cudaVideoCodec::cudaVideoCodec_AV1, other => return Err(codec_err(format!("NVDEC {} decode is not wired", other.label()))), }; // `CudaContext::new` retains the device's primary context, the same one // the NVENC backend uses, so frames pass between them without a copy. let ctx = CudaContext::new(0).map_err(|e| codec_err(format!("CUDA init: {e:?}")))?; let mut state = Box::new(State { api, ctx, resize: config.scale_hint, decoder: None, ready: Vec::new(), error: None, }); let mut params = CUVIDPARSERPARAMS { CodecType: cuda_codec, // Placeholder until the sequence callback reports the real minimum. ulMaxNumDecodeSurfaces: 1, // Packet timestamps are in microseconds (default is a 10 MHz clock). ulClockRate: 1_000_000, // Emit each picture as soon as it decodes (live path, no lookahead). ulMaxDisplayDelay: 0, pUserData: &mut *state as *mut State as *mut c_void, pfnSequenceCallback: Some(sequence_callback), pfnDecodePicture: Some(decode_callback), pfnDisplayPicture: Some(display_callback), ..Default::default() }; let mut parser: CUvideoparser = ptr::null_mut(); // SAFETY: params points at a fully-initialized struct and outlives the // call; the user-data pointer stays valid because `state` is boxed and // owned by the returned backend alongside the parser. let result = unsafe { (api.create_video_parser)(&mut parser, &mut params) }; if result != cudarc::driver::sys::CUresult::CUDA_SUCCESS { return Err(codec_err(format!("cuvidCreateVideoParser: {result:?}"))); } tracing::info!(decoder = NAME, codec = ?codec, resize = ?config.scale_hint, "opened video decoder"); Ok(Box::new(Self { parser, state, discontinuity: false, })) } /// Submit one parser packet and copy every picture its display callback made /// ready before returning. fn parse(&mut self, packet: &mut CUVIDSOURCEDATAPACKET) -> Result, Error> { // Parser callbacks and the map/copy below all need the CUDA context current // on this thread. self.state .ctx .bind_to_thread() .map_err(|e| codec_err(format!("CUDA bind: {e:?}")))?; // SAFETY: parser and packet are valid. A non-null payload is owned by the // caller for the duration of this synchronous call. let result = unsafe { (self.state.api.parse_video_data)(self.parser, packet) }; if let Some(error) = self.state.error.take() { self.state.ready.clear(); return Err(codec_err(error)); } if result != cudarc::driver::sys::CUresult::CUDA_SUCCESS { self.state.ready.clear(); return Err(codec_err(format!("cuvidParseVideoData: {result:?}"))); } let ready = std::mem::take(&mut self.state.ready); ready.iter().map(|disp| self.state.map_frame(disp)).collect() } } impl Backend for Nvdec { fn decode(&mut self, access_unit: Bytes, timestamp: Timestamp, _keyframe: bool) -> Result, Error> { let mut flags = (CUvideopacketflags::CUVID_PKT_TIMESTAMP as c_ulong) | (CUvideopacketflags::CUVID_PKT_ENDOFPICTURE as c_ulong); if self.discontinuity { flags |= CUvideopacketflags::CUVID_PKT_DISCONTINUITY as c_ulong; } let mut packet = CUVIDSOURCEDATAPACKET { // ENDOFPICTURE: each payload is one complete access unit, so the // parser sees the picture boundary without waiting for the next AU. flags, payload_size: access_unit.len() as c_ulong, payload: access_unit.as_ptr(), // cuvid's clock rate is microseconds (set at parser creation). timestamp: timestamp.as_micros() as i64, }; let frames = self.parse(&mut packet)?; self.discontinuity = false; Ok(frames) } fn flush(&mut self) -> Result, Error> { let mut packet = CUVIDSOURCEDATAPACKET { // NVIDIA requires an explicit EOS packet to release every picture still // waiting in display order, even with zero display delay. flags: CUvideopacketflags::CUVID_PKT_ENDOFSTREAM as c_ulong, payload_size: 0, payload: ptr::null(), timestamp: 0, }; let frames = self.parse(&mut packet)?; self.discontinuity = true; Ok(frames) } fn name(&self) -> &str { NAME } } impl Drop for Nvdec { fn drop(&mut self) { // Drop may run on a different thread than decode; destroying the parser // needs the context current (the decoder binds its own). let _ = self.state.ctx.bind_to_thread(); // SAFETY: the parser is valid and no parse call is in flight (&mut self). unsafe { (self.state.api.destroy_video_parser)(self.parser) }; } } impl State { /// Sequence callback body: (re)create the decoder for the reported format. /// Returns the decode-surface count the parser should allocate for. fn on_sequence(&mut self, format: &CUVIDEOFORMAT) -> Result { if format.chroma_format != cudaVideoChromaFormat::cudaVideoChromaFormat_420 { return Err(format!("unsupported chroma format {:?}", format.chroma_format)); } if format.bit_depth_luma_minus8 != 0 || format.bit_depth_chroma_minus8 != 0 { return Err(format!( "unsupported bit depth {} (only 8-bit is supported)", format.bit_depth_luma_minus8 + 8 )); } let mut caps = CUVIDDECODECAPS { eCodecType: format.codec, eChromaFormat: format.chroma_format, nBitDepthMinus8: 0, ..Default::default() }; // SAFETY: caps is fully initialized and the CUDA context is current (the // callback runs inside `decode`, which binds it). let result = unsafe { (self.api.get_decoder_caps)(&mut caps) }; if result != cudarc::driver::sys::CUresult::CUDA_SUCCESS { return Err(format!("cuvidGetDecoderCaps: {result:?}")); } if caps.bIsSupported == 0 { return Err(format!("this GPU cannot decode {:?}", format.codec)); } if caps.nOutputFormatMask & (1 << cudaVideoSurfaceFormat::cudaVideoSurfaceFormat_NV12 as u16) == 0 { return Err("this GPU cannot output NV12".into()); } if format.coded_width > caps.nMaxWidth || format.coded_height > caps.nMaxHeight { return Err(format!( "{}x{} exceeds this GPU's {}x{} decode limit", format.coded_width, format.coded_height, caps.nMaxWidth, caps.nMaxHeight )); } // Output size: the requested resize, or the display area (the coded size // minus cropping). Rounded down to even for NV12. let display = crate::Size::new( (format.display_area.right - format.display_area.left).max(2) as u32 & !1, (format.display_area.bottom - format.display_area.top).max(2) as u32 & !1, ); let crate::Size { width, height } = self.resize.unwrap_or(display); let coded = (format.coded_width, format.coded_height); let display_area = ( format.display_area.left, format.display_area.top, format.display_area.right, format.display_area.bottom, ); let surfaces = c_int::from(format.min_num_decode_surfaces.max(1)); // A repeated sequence header with unchanged geometry (common at every // keyframe) keeps the existing decoder. The display crop matters even at // the same coded and target sizes: it selects the source rect the scaler // reads from. if let Some(decoder) = &self.decoder && decoder.coded == coded && decoder.display_area == display_area && (decoder.width, decoder.height) == (width, height) { return Ok(surfaces); } self.decoder = None; let mut info = CUVIDDECODECREATEINFO { ulWidth: format.coded_width as c_ulong, ulHeight: format.coded_height as c_ulong, ulNumDecodeSurfaces: surfaces as c_ulong, CodecType: format.codec, ChromaFormat: format.chroma_format, ulCreationFlags: cudaVideoCreateFlags::cudaVideoCreate_PreferCUVID as c_ulong, bitDepthMinus8: 0, // We recreate on geometry changes instead of reconfiguring, so no // headroom beyond the current coded size is needed. ulMaxWidth: format.coded_width as c_ulong, ulMaxHeight: format.coded_height as c_ulong, OutputFormat: cudaVideoSurfaceFormat::cudaVideoSurfaceFormat_NV12, // moq sources are progressive; Weave passes frames through untouched. DeinterlaceMode: cudaVideoDeinterlaceMode::cudaVideoDeinterlaceMode_Weave, ulTargetWidth: width as c_ulong, ulTargetHeight: height as c_ulong, // Each output surface is copied out and unmapped before the next map, // so 2 is plenty (1 in flight + 1 being post-processed). ulNumOutputSurfaces: 2, ..Default::default() }; info.display_area.left = format.display_area.left as i16; info.display_area.top = format.display_area.top as i16; info.display_area.right = format.display_area.right as i16; info.display_area.bottom = format.display_area.bottom as i16; info.target_rect.right = width as i16; info.target_rect.bottom = height as i16; let mut handle: CUvideodecoder = ptr::null_mut(); // SAFETY: info is fully initialized and the CUDA context is current. let result = unsafe { (self.api.create_decoder)(&mut handle, &mut info) }; if result != cudarc::driver::sys::CUresult::CUDA_SUCCESS { return Err(format!("cuvidCreateDecoder: {result:?}")); } tracing::debug!( decoder = NAME, coded_width = format.coded_width, coded_height = format.coded_height, width, height, "created cuvid decoder" ); self.decoder = Some(Decoder { api: self.api, ctx: self.ctx.clone(), handle, coded, display_area, width, height, }); Ok(surfaces) } /// Map one decoded picture and copy it device-to-device into an owned CUDA /// buffer, so the fixed surface pool is released before the next decode. fn map_frame(&self, disp: &CUVIDPARSERDISPINFO) -> Result { let decoder = self .decoder .as_ref() .ok_or_else(|| codec_err("display callback fired before a decoder exists".into()))?; let mut proc_params = CUVIDPROCPARAMS { progressive_frame: disp.progressive_frame, top_field_first: disp.top_field_first, ..Default::default() }; let mut dev_ptr: c_ulonglong = 0; let mut pitch: c_uint = 0; // SAFETY: the decoder handle is valid, the picture index came from the // display callback, and the CUDA context is current. let result = unsafe { (self.api.map_video_frame)( decoder.handle, disp.picture_index, &mut dev_ptr, &mut pitch, &mut proc_params, ) }; if result != cudarc::driver::sys::CUresult::CUDA_SUCCESS { return Err(codec_err(format!("cuvidMapVideoFrame: {result:?}"))); } // The mapped surface is NV12 at the decoder's target size: `height` luma // rows of `pitch` bytes, then `height / 2` interleaved-UV rows. Copy it // linearly into an owned buffer with the identical layout. let copied = (|| -> Result { let frame = cuda::Frame::alloc(&self.ctx, decoder.width, decoder.height, pitch)?; let len = pitch as usize * decoder.height as usize * 3 / 2; // SAFETY: both regions are `len` bytes of device memory: the mapped // surface by cuvid's layout above, the destination by `alloc`. unsafe { cudarc::driver::result::memcpy_dtod_sync(frame.device_ptr(), dev_ptr, len) } .map_err(|e| codec_err(format!("CUDA device copy: {e:?}")))?; Ok(frame) })(); // SAFETY: unmapping the pointer we just mapped, always, even when the // copy failed; a leaked mapping would starve the output-surface pool. unsafe { (self.api.unmap_video_frame)(decoder.handle, dev_ptr) }; // Timestamps rode the parser from `decode` (microseconds, unsigned). let timestamp = Timestamp::from_micros(disp.timestamp.max(0) as u64).unwrap_or(Timestamp::ZERO); Ok(Frame::new(Surface::Cuda(copied?), timestamp)) } } /// Sequence callback: a new (or repeated) sequence header. Returns the number of /// decode surfaces the parser should assume, or 0 on failure. unsafe extern "C" fn sequence_callback(user: *mut c_void, format: *mut CUVIDEOFORMAT) -> c_int { // SAFETY: `user` is the boxed State passed at parser creation, and cuvid // invokes callbacks synchronously inside the parse call, so no other // reference to it is live. let state = unsafe { &mut *(user as *mut State) }; let format = unsafe { &*format }; match state.on_sequence(format) { Ok(surfaces) => surfaces, Err(e) => { state.error.get_or_insert(e); 0 } } } /// Decode callback: a picture is ready to be submitted to the hardware. unsafe extern "C" fn decode_callback(user: *mut c_void, pic: *mut CUVIDPICPARAMS) -> c_int { // SAFETY: see `sequence_callback`. let state = unsafe { &mut *(user as *mut State) }; let Some(decoder) = &state.decoder else { state .error .get_or_insert("decode callback fired before a decoder exists".into()); return 0; }; // SAFETY: the decoder handle is valid and `pic` comes straight from cuvid. let result = unsafe { (state.api.decode_picture)(decoder.handle, pic) }; if result != cudarc::driver::sys::CUresult::CUDA_SUCCESS { state.error.get_or_insert(format!("cuvidDecodePicture: {result:?}")); return 0; } 1 } /// Display callback: a decoded picture is ready for output, in presentation /// order. Queue it; `decode` maps and copies it out after the parse call. unsafe extern "C" fn display_callback(user: *mut c_void, disp: *mut CUVIDPARSERDISPINFO) -> c_int { // SAFETY: see `sequence_callback`. let state = unsafe { &mut *(user as *mut State) }; if disp.is_null() { // End-of-stream marker; nothing to queue. return 1; } // SAFETY: non-null `disp` points at a valid dispinfo for this call. state.ready.push(unsafe { *disp }); 1 } /// Whether libcuda can be dlopen'd. cudarc loads it lazily and panics if it's /// absent, so probe first and turn a missing driver into a recoverable `Err` /// (libnvcuvid is probed by the cuvid table itself). fn driver_libs_present() -> bool { const CUDA: &[&str] = &["libcuda.so.1", "libcuda.so"]; // SAFETY: we only open the library to test presence and immediately drop the // handle; loading runs the driver lib's initializers, which is sound. CUDA.iter() .any(|name| unsafe { libloading::Library::new(*name) }.is_ok()) } #[cfg(test)] mod tests { use super::*; use crate::decode::{Config as DecodeConfig, Kind as DecodeKind}; use crate::encode::{Codec as EncodeCodec, Config as EncodeConfig, Encoder, Kind as EncodeKind}; use crate::frame::I420; /// Real hardware only: skip (return) on a box without the NVIDIA driver, so /// these are no-ops on GPU-less CI and validate on a Linux + NVIDIA box. fn hw_available() -> bool { driver_libs_present() && cuvid::Api::get().is_ok() } /// On a host without the NVIDIA driver, opening NVDEC must return an `Err` /// (so `Kind::Auto` falls back to openh264) rather than panicking in cudarc /// or the cuvid loader. On a box that does have the driver this is a no-op. #[test] fn missing_driver_errors_instead_of_panicking() { if hw_available() { return; } assert!(Nvdec::open(Codec::H264, &decode_config(None)).is_err()); } fn decode_config(scale_hint: Option) -> DecodeConfig { DecodeConfig { kind: DecodeKind::Named(NAME.into()), scale_hint, ..DecodeConfig::new() } } /// A static RGBA gradient that varies in both axes, so the chroma planes have /// spatial structure and a pitch/layout bug corrupts the decoded image. fn gradient_rgba(width: u32, height: u32) -> Vec { let (w, h) = (width as usize, height as usize); let mut buf = vec![0u8; w * h * 4]; for y in 0..h { for x in 0..w { let i = (y * w + x) * 4; buf[i] = (x * 255 / w) as u8; buf[i + 1] = (y * 255 / h) as u8; buf[i + 2] = ((x + y) * 255 / (w + h)) as u8; buf[i + 3] = 255; } } buf } /// Wrap a gradient RGBA buffer as the `index`th frame of a 30fps stream. fn gradient_frame(rgba: &[u8], w: u32, h: u32, index: u64) -> crate::Frame { let surface = crate::Surface::rgba(rgba, crate::Size::new(w, h)).unwrap(); crate::Frame::new(surface, Timestamp::from_micros(index * 33_333).unwrap()) } /// Mean absolute error between two equal-length planes. fn mae(a: &[u8], b: &[u8]) -> u64 { assert_eq!(a.len(), b.len()); a.iter().zip(b).map(|(x, y)| x.abs_diff(*y) as u64).sum::() / a.len() as u64 } /// Encode `frames` gradient frames with `encoder` and decode them through /// NVDEC, returning the downloaded I420 pictures with their timestamps. fn round_trip(mut encoder: Encoder, mut decoder: Box, w: u32, h: u32) -> Vec<(u64, I420)> { let rgba = gradient_rgba(w, h); let mut out = Vec::new(); for i in 0..10u64 { if i == 0 { encoder.cut().unwrap(); } for encoded in encoder.encode(&gradient_frame(&rgba, w, h, i)).unwrap() { for decoded in decoder.decode(encoded.payload, encoded.timestamp, i == 0).unwrap() { let i420 = decoded.surface.to_i420().unwrap().into_owned(); out.push((decoded.timestamp.as_micros() as u64, i420)); } } } for decoded in decoder.flush().unwrap() { let i420 = decoded.surface.to_i420().unwrap().into_owned(); out.push((decoded.timestamp.as_micros() as u64, i420)); } assert!(!out.is_empty(), "NVDEC produced no frames"); out } /// H.264 through the real hardware: openh264 encodes a gradient (Annex-B, /// inline SPS/PPS) and NVDEC decodes it. Asserts the downloaded NV12 -> I420 /// picture matches the input (a layout bug shears a plane and blows the MAE) /// and that timestamps ride the parser through 1:1. #[test] fn nvdec_h264_round_trip() { if !hw_available() { return; } let (w, h) = (320u32, 240u32); let encoder = Encoder::new(&EncodeConfig { kind: EncodeKind::Software, ..EncodeConfig::new(w, h, crate::FrameRate::new(30, 1).unwrap()) }) .unwrap(); let decoder = Nvdec::open(Codec::H264, &decode_config(None)).expect("NVDEC H.264 decoder"); let expected = I420::from_rgba(&gradient_rgba(w, h), w * 4, crate::Size::new(w, h)).unwrap(); let decoded = round_trip(encoder, decoder, w, h); for (i, (timestamp, i420)) in decoded.iter().enumerate() { // Zero display delay and no B-frames: one-in one-out, in order. assert_eq!(*timestamp, i as u64 * 33_333, "timestamp did not ride the parser"); assert_eq!((i420.width, i420.height), (w, h)); assert!(mae(i420.y(), expected.y()) < 8, "Y plane corrupt"); assert!(mae(i420.u(), expected.u()) < 8, "U plane corrupt"); assert!(mae(i420.v(), expected.v()) < 8, "V plane corrupt"); } } /// The decoder-side scaler: decode 320x240 at a 160x120 target and compare /// against a CPU downscale of the input. Loose MAE bound (two different /// scaling kernels), but a plane swap or a pitch bug still blows it up. #[test] fn nvdec_resize_scales_output() { if !hw_available() { return; } let (w, h) = (320u32, 240u32); let encoder = Encoder::new(&EncodeConfig { kind: EncodeKind::Software, ..EncodeConfig::new(w, h, crate::FrameRate::new(30, 1).unwrap()) }) .unwrap(); let decoder = Nvdec::open(Codec::H264, &decode_config(Some(crate::Size::new(160, 120)))).expect("NVDEC H.264 decoder"); // Nearest-neighbor reference downscale of the expected picture. let full = I420::from_rgba(&gradient_rgba(w, h), w * 4, crate::Size::new(w, h)).unwrap(); let sample = |plane: &[u8], pw: usize, x: usize, y: usize| plane[y * 2 * pw + x * 2]; let mut expected_y = vec![0u8; 160 * 120]; for y in 0..120 { for x in 0..160 { expected_y[y * 160 + x] = sample(full.y(), w as usize, x, y); } } let decoded = round_trip(encoder, decoder, w, h); for (_, i420) in &decoded { assert_eq!((i420.width, i420.height), (160, 120), "resize was not applied"); assert!(mae(i420.y(), &expected_y) < 12, "scaled Y plane corrupt"); } } /// H.265 has no software encoder, so the HEVC round trip rides NVENC on the /// encode side and NVDEC on the decode side. #[test] fn nvdec_h265_round_trip() { if !hw_available() { return; } let (w, h) = (320u32, 240u32); let Ok(encoder) = Encoder::new(&EncodeConfig { codec: EncodeCodec::H265, kind: EncodeKind::Named("nvenc".into()), ..EncodeConfig::new(w, h, crate::FrameRate::new(30, 1).unwrap()) }) else { // Driver present but NVENC unusable (e.g. GPU busy); don't fail. return; }; let decoder = Nvdec::open(Codec::H265, &decode_config(None)).expect("NVDEC H.265 decoder"); let expected = I420::from_rgba(&gradient_rgba(w, h), w * 4, crate::Size::new(w, h)).unwrap(); let decoded = round_trip(encoder, decoder, w, h); for (_, i420) in &decoded { assert_eq!((i420.width, i420.height), (w, h)); assert!(mae(i420.y(), expected.y()) < 8, "Y plane corrupt"); assert!(mae(i420.u(), expected.u()) < 8, "U plane corrupt"); assert!(mae(i420.v(), expected.v()) < 8, "V plane corrupt"); } } /// The full zero-copy loop: NVDEC decodes to CUDA frames, NVENC re-encodes /// them straight from device memory (the registered-resource path), and a /// software decode of the re-encoded stream still matches the original /// picture. No CPU pixels exist between NVDEC and NVENC. #[test] fn nvdec_to_nvenc_zero_copy() { if !hw_available() { return; } let (w, h) = (320u32, 240u32); // Source stream: software-encoded gradient. let source = Encoder::new(&EncodeConfig { kind: EncodeKind::Software, ..EncodeConfig::new(w, h, crate::FrameRate::new(30, 1).unwrap()) }) .unwrap(); // Decode at half size so the hardware scaler is in the loop too. let decoder = Nvdec::open(Codec::H264, &decode_config(Some(crate::Size::new(160, 120)))).expect("NVDEC decoder"); let mut nvenc = Encoder::new(&EncodeConfig { kind: EncodeKind::Named("nvenc".into()), ..EncodeConfig::new(160, 120, crate::FrameRate::new(30, 1).unwrap()) }) .expect("NVENC encoder"); // GPU transcode: every decoded frame must be CUDA-resident. let decoded = { let rgba = gradient_rgba(w, h); let mut source = source; let mut decoder = decoder; let mut frames = Vec::new(); for i in 0..10u64 { if i == 0 { source.cut().unwrap(); } for encoded in source.encode(&gradient_frame(&rgba, w, h, i)).unwrap() { frames.extend(decoder.decode(encoded.payload, encoded.timestamp, i == 0).unwrap()); } } frames }; assert!(!decoded.is_empty()); let mut packets = Vec::new(); for (i, out) in decoded.into_iter().enumerate() { assert!( matches!(out.surface, Surface::Cuda(_)), "NVDEC produced a non-CUDA frame; the zero-copy path is not exercised" ); if i == 0 { nvenc.cut().unwrap(); } packets.extend(nvenc.encode(&out).unwrap()); } packets.extend(nvenc.finish().unwrap()); assert!(!packets.is_empty(), "NVENC produced no packets from CUDA frames"); // Decode the re-encoded stream in software and compare to the source. let expected = { let full = I420::from_rgba(&gradient_rgba(w, h), w * 4, crate::Size::new(w, h)).unwrap(); let mut y = vec![0u8; 160 * 120]; for row in 0..120 { for col in 0..160 { y[row * 160 + col] = full.y()[row * 2 * w as usize + col * 2]; } } y }; let mut check = crate::decode::backend::open( crate::decode::backend::Codec::H264, &DecodeConfig { kind: DecodeKind::Software, ..DecodeConfig::new() }, ) .unwrap(); let mut last = None; for (i, encoded) in packets.into_iter().enumerate() { for out in check.decode(encoded.payload, encoded.timestamp, i == 0).unwrap() { last = Some(out.surface.to_i420().unwrap().into_owned()); } } let last = last.expect("software decoder produced no frames"); assert_eq!((last.width, last.height), (160, 120)); assert!(mae(last.y(), &expected) < 16, "transcoded Y plane corrupt"); } }