mod ffi;
mod loader;
pub use loader::is_nvjpeg_available;
use crate::error::CodecError;
use crate::jpeg::markers::JpegHeaders;
use crate::options::ImageInfo;
use crate::pixel::ImagePixel;
use edgefirst_tensor::{PixelFormat, Tensor};
use ffi::*;
use std::os::raw::c_uchar;
const MAX_CONSECUTIVE_FAILURES: u32 = 8;
pub(crate) enum NvJpegDecode {
Fallback(String),
Fatal(CodecError),
}
enum DecodeErr {
Reset(String),
Unsupported(String),
Fatal(CodecError),
}
#[allow(clippy::large_enum_variant)]
#[derive(Default)]
pub(crate) enum NvJpegProbe {
#[default]
Unprobed,
Unavailable,
Ready(NvJpegContext),
}
pub(crate) struct NvJpegContext {
handle: NvjpegHandle,
state: NvjpegJpegState,
stream: edgefirst_tensor::CudaStream,
failures: u32,
}
unsafe impl Send for NvJpegContext {}
impl NvJpegContext {
fn new() -> Option<Self> {
let lib = loader::lib()?;
if !edgefirst_tensor::is_cuda_available() {
return None;
}
let stream = edgefirst_tensor::stream_create()?;
let mut handle: NvjpegHandle = std::ptr::null_mut();
let st = unsafe {
(lib.create_ex)(
NVJPEG_BACKEND_DEFAULT,
std::ptr::null(),
std::ptr::null(),
0,
&mut handle,
)
};
if st != NVJPEG_STATUS_SUCCESS {
log::debug!("nvjpegCreateEx failed (status {st}); nvjpeg unavailable");
unsafe { edgefirst_tensor::stream_destroy(stream) };
return None;
}
let mut state: NvjpegJpegState = std::ptr::null_mut();
if unsafe { (lib.jpeg_state_create)(handle, &mut state) } != NVJPEG_STATUS_SUCCESS {
unsafe {
(lib.destroy)(handle);
edgefirst_tensor::stream_destroy(stream);
}
return None;
}
log::info!("nvjpeg context ready (BACKEND_DEFAULT)");
Some(Self {
handle,
state,
stream,
failures: 0,
})
}
fn decode<T: ImagePixel>(
&self,
data: &[u8],
dst: &mut Tensor<T>,
output_fmt: PixelFormat,
img_w: usize,
img_h: usize,
) -> Result<ImageInfo, DecodeErr> {
let _span = tracing::trace_span!(
"codec.decode_jpeg.nvjpeg",
w = img_w,
h = img_h,
n_bytes = data.len(),
target = "rgbi",
)
.entered();
match self.decode_reconfigured(data, dst, img_w, img_h) {
Ok(info) => Ok(info),
Err(e) => {
let _ = dst.configure_image(img_w, img_h, output_fmt);
Err(e)
}
}
}
fn decode_reconfigured<T: ImagePixel>(
&self,
data: &[u8],
dst: &mut Tensor<T>,
img_w: usize,
img_h: usize,
) -> Result<ImageInfo, DecodeErr> {
let lib =
loader::lib().ok_or_else(|| DecodeErr::Reset("nvjpeg library vanished".into()))?;
dst.configure_image(img_w, img_h, PixelFormat::Rgb)
.map_err(|e| DecodeErr::Unsupported(format!("Rgb reconfigure failed: {e}")))?;
let rgb_stride = dst
.effective_row_stride()
.ok_or_else(|| DecodeErr::Reset("no row stride after Rgb reconfigure".into()))?;
self.decode_into_device(lib, dst, data, img_w, img_h, rgb_stride)?;
Ok(ImageInfo {
width: img_w,
height: img_h,
format: PixelFormat::Rgb,
row_stride: rgb_stride,
rotation_degrees: 0,
flip_horizontal: false,
})
}
fn decode_into_device<T: ImagePixel>(
&self,
lib: &loader::NvjpegLib,
dst: &Tensor<T>,
data: &[u8],
img_w: usize,
img_h: usize,
rgb_stride: usize,
) -> Result<(), DecodeErr> {
let cuda = {
let _s = tracing::trace_span!("codec.decode_jpeg.nvjpeg_map").entered();
dst.cuda_map()
.ok_or_else(|| DecodeErr::Reset("cuda_map returned None".into()))?
};
let base = cuda.device_ptr() as *mut c_uchar;
if base.is_null() {
return Err(DecodeErr::Reset(
"cuda_map returned a null device pointer".into(),
));
}
let map_len = cuda.len();
let offset = dst.plane_offset().unwrap_or(0);
let needed = img_h.checked_mul(rgb_stride).ok_or_else(|| {
DecodeErr::Fatal(CodecError::InvalidData(format!(
"nvjpeg: RGB geometry {img_w}x{img_h} (stride {rgb_stride}) overflows usize"
)))
})?;
if offset.checked_add(needed).is_none_or(|end| end > map_len) {
return Err(DecodeErr::Unsupported(format!(
"RGB {img_w}x{img_h} ({needed} B) + offset {offset} exceeds PBO mapping ({map_len} B)"
)));
}
let mut ncomp: i32 = 0;
let mut subsampling: i32 = 0;
let mut widths = [0i32; NVJPEG_MAX_COMPONENT];
let mut heights = [0i32; NVJPEG_MAX_COMPONENT];
let st = unsafe {
(lib.get_image_info)(
self.handle,
data.as_ptr(),
data.len(),
&mut ncomp,
&mut subsampling,
widths.as_mut_ptr(),
heights.as_mut_ptr(),
)
};
if st != NVJPEG_STATUS_SUCCESS {
return Err(DecodeErr::Reset(format!("nvjpegGetImageInfo status {st}")));
}
if widths[0] as usize != img_w || heights[0] as usize != img_h {
return Err(DecodeErr::Reset(format!(
"nvjpeg dims {}x{} != header {img_w}x{img_h}",
widths[0], heights[0]
)));
}
let mut image = NvjpegImage::default();
image.channel[0] = unsafe { base.add(offset) };
image.pitch[0] = rgb_stride;
{
let _s = tracing::trace_span!("codec.decode_jpeg.nvjpeg_submit").entered();
let st = unsafe {
(lib.decode)(
self.handle,
self.state,
data.as_ptr(),
data.len(),
NVJPEG_OUTPUT_RGBI,
&mut image,
self.stream,
)
};
if st != NVJPEG_STATUS_SUCCESS {
return Err(DecodeErr::Reset(format!("nvjpegDecode status {st}")));
}
}
{
let _s = tracing::trace_span!("codec.decode_jpeg.nvjpeg_sync").entered();
if !unsafe { edgefirst_tensor::stream_synchronize(self.stream) } {
return Err(DecodeErr::Reset("cudaStreamSynchronize failed".into()));
}
}
let _s = tracing::trace_span!("codec.decode_jpeg.nvjpeg_unmap").entered();
drop(cuda);
Ok(())
}
}
impl Drop for NvJpegContext {
fn drop(&mut self) {
if let Some(lib) = loader::lib() {
unsafe {
(lib.jpeg_state_destroy)(self.state);
(lib.destroy)(self.handle);
}
}
unsafe { edgefirst_tensor::stream_destroy(self.stream) };
}
}
impl NvJpegProbe {
fn ensure_probed(&mut self) -> Option<&mut NvJpegContext> {
if matches!(self, NvJpegProbe::Unprobed) {
*self = match NvJpegContext::new() {
Some(ctx) => NvJpegProbe::Ready(ctx),
None => NvJpegProbe::Unavailable,
};
}
match self {
NvJpegProbe::Ready(ctx) => Some(ctx),
_ => None,
}
}
#[allow(clippy::too_many_arguments)]
pub(crate) fn try_decode<T: ImagePixel>(
&mut self,
data: &[u8],
_headers: &JpegHeaders,
dst: &mut Tensor<T>,
output_fmt: PixelFormat,
img_w: usize,
img_h: usize,
_dst_stride: usize,
) -> Result<Option<ImageInfo>, NvJpegDecode> {
let Some(ctx) = self.ensure_probed() else {
return Ok(None);
};
if dst.cuda().is_none() {
return Ok(None);
}
let result = ctx.decode::<T>(data, dst, output_fmt, img_w, img_h);
let out = match result {
Ok(info) => {
ctx.failures = 0;
Ok(Some(info))
}
Err(DecodeErr::Reset(why)) => {
ctx.failures = ctx.failures.saturating_add(1);
Err(NvJpegDecode::Fallback(why))
}
Err(DecodeErr::Unsupported(why)) => Err(NvJpegDecode::Fallback(why)),
Err(DecodeErr::Fatal(e)) => Err(NvJpegDecode::Fatal(e)),
};
if let NvJpegProbe::Ready(ctx) = self {
if ctx.failures >= MAX_CONSECUTIVE_FAILURES {
log::warn!(
"nvjpeg decoder disabled after {} consecutive failures",
ctx.failures
);
*self = NvJpegProbe::Unavailable;
}
}
out
}
}
#[cfg(test)]
mod tests {
use super::*;
use edgefirst_tensor::TensorMemory;
#[test]
fn mem_tensor_falls_through_untouched() {
let mut probe = NvJpegProbe::default();
let mut dst = Tensor::<u8>::image(
64,
64,
PixelFormat::Nv12,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let headers =
crate::jpeg::markers::parse_markers(MINIMAL_JPEG).expect("MINIMAL_JPEG must parse");
let r = probe.try_decode::<u8>(
MINIMAL_JPEG,
&headers,
&mut dst,
PixelFormat::Nv12,
8,
8,
16,
);
assert!(matches!(r, Ok(None)));
assert_eq!(dst.format(), Some(PixelFormat::Nv12));
}
const MINIMAL_JPEG: &[u8] = &[
0xFF, 0xD8, 0xFF, 0xDB, 0x00, 0x43, 0x00, 0x08, 0x06, 0x06, 0x07, 0x06, 0x05, 0x08, 0x07,
0x07, 0x07, 0x09, 0x09, 0x08, 0x0A, 0x0C, 0x14, 0x0D, 0x0C, 0x0B, 0x0B, 0x0C, 0x19, 0x12,
0x13, 0x0F, 0x14, 0x1D, 0x1A, 0x1F, 0x1E, 0x1D, 0x1A, 0x1C, 0x1C, 0x20, 0x24, 0x2E, 0x27,
0x20, 0x22, 0x2C, 0x23, 0x1C, 0x1C, 0x28, 0x37, 0x29, 0x2C, 0x30, 0x31, 0x34, 0x34, 0x34,
0x1F, 0x27, 0x39, 0x3D, 0x38, 0x32, 0x3C, 0x2E, 0x33, 0x34, 0x32, 0xFF, 0xC0, 0x00, 0x0B,
0x08, 0x00, 0x08, 0x00, 0x08, 0x01, 0x01, 0x11, 0x00, 0xFF, 0xC4, 0x00, 0x1F, 0x00, 0x00,
0x01, 0x05, 0x01, 0x01, 0x01, 0x01, 0x01, 0x01, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00, 0x00,
0x00, 0x01, 0x02, 0x03, 0x04, 0x05, 0x06, 0x07, 0x08, 0x09, 0x0A, 0x0B, 0xFF, 0xDA, 0x00,
0x08, 0x01, 0x01, 0x00, 0x00, 0x3F, 0x00, 0xD2, 0xCF, 0x20, 0xFF, 0xD9,
];
#[test]
fn unavailable_probe_returns_none_without_touching_tensor() {
let mut probe = NvJpegProbe::Unavailable;
let mut dst = Tensor::<u8>::image(
64,
64,
PixelFormat::Nv12,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let headers =
crate::jpeg::markers::parse_markers(MINIMAL_JPEG).expect("MINIMAL_JPEG must parse");
let r = probe.try_decode::<u8>(
MINIMAL_JPEG,
&headers,
&mut dst,
PixelFormat::Nv12,
8,
8,
16,
);
assert!(
matches!(r, Ok(None)),
"Unavailable probe must return Ok(None)"
);
assert_eq!(dst.format(), Some(PixelFormat::Nv12));
}
#[test]
fn circuit_breaker_threshold_constant_is_positive() {
const _: () = assert!(MAX_CONSECUTIVE_FAILURES >= 2);
}
#[test]
fn unprobed_probe_degrades_to_unavailable_without_nvjpeg() {
if is_nvjpeg_available() {
return;
}
let mut probe = NvJpegProbe::default();
assert!(
matches!(probe, NvJpegProbe::Unprobed),
"default must be Unprobed"
);
let mut dst = Tensor::<u8>::image(
64,
64,
PixelFormat::Nv12,
Some(TensorMemory::Mem),
edgefirst_tensor::CpuAccess::ReadWrite,
)
.unwrap();
let headers =
crate::jpeg::markers::parse_markers(MINIMAL_JPEG).expect("MINIMAL_JPEG must parse");
let r = probe.try_decode::<u8>(
MINIMAL_JPEG,
&headers,
&mut dst,
PixelFormat::Nv12,
8,
8,
16,
);
assert!(matches!(r, Ok(None)), "no-CUDA probe must return Ok(None)");
assert!(
matches!(probe, NvJpegProbe::Unavailable),
"probe must be Unavailable after first call on a non-CUDA host"
);
}
}