Browse / github.com/storytold/filmcraft / patch
platform: export a decoded VA-API surface as DMABUF (Linux)
platform: export a decoded VA-API surface as DMABUF (Linux) Depends on #3214: this branch contains its commit. Only the last commit (2cbab597) belongs to this PR; merge #3214 first. It uses the optional-symbol macro `sym_opt!` that #3214 adds. ## What The first half of zero-copy playback: a handle to a decoded surface that a GPU importer can bind, so the picture need not be read back to the CPU and uploaded again. Nothing imports it yet; the readback stays the only path. - ffi.rs: the DRM PRIME 2 export structures and constants from va_drmcommon.h / va.h; abi_tests.rs pins their sizes, alignments and offsets against the C compiler's view (312 / 8 de
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- zsoli69 (imported from pull request #3222, open)
- Base
10d626ccc4888e0f020f9ca314c2bc9148ded4f4- Size
- +413 −29 in 11 files
- Review
- pending
- Shared
- 2026-10-28
- Id
1b31f598ecd128b1ca8dac57
How it went
No other operator has reported using it yet.
Diff
raw · crates/platform/README.md crates/platform/src/biplanar.rs crates/platform/src/media_foundation/gpu.rs crates/platform/src/nvdec/cuvid.rs crates/platform/src/vaapi/abi_tests.rs crates/platform/src/vaapi/ffi.rs crates/platform/src/vaapi/tests.rs crates/platform/src/vaapi/va.rs docs/adr/0001-platform-ffi.md docs/hardware-parity.md docs/performance.md
@@ -105,8 +105,12 @@ let availability = filmcraft_platform::register(); // Available("VideoToolbox") bit-exact. Each picture is sent as one picture-parameter buffer, the scaling matrices, and a slice parameter + slice data buffer per slice (the NAL unit as stored, emulation prevention included). HEVC pictures are sent the same way per slice segment (scaling lists only when enabled, converted- to raster order). Pictures the DPB outputs are read back right away (`vaGetImage` into an NV12 or- P010 image, then `biplanar.rs`), **the one GPU to CPU copy**. Surfaces: the stream's DPB size plus two.+ to raster order). Pictures the DPB outputs are read back right away (`vaDeriveImage`, which maps the+ surface's own NV12 or P010 memory, or `vaGetImage` into an image where a driver cannot derive it;+ then `biplanar.rs`), **the one GPU to CPU copy**. Surfaces: the stream's DPB size plus two.+ [`va::Display::export_surface`](src/vaapi/va.rs) can hand a decoded surface over as DMABUF+ (DRM PRIME 2: plane formats, offsets, pitches, the objects' file descriptors and their DRM format+ modifiers) instead of reading it back; nothing imports it into the compositor yet. - *frame_num gaps* (and every start at a non-IDR picture: an open-GOP seek) get "non-existing" frames like the software decoder's: copies of the latest decoded frame, mid-gray when there is none (`vaPutImage`). Pictures predicted from them (the leading pictures of an open GOP after a@@ -341,7 +345,11 @@ Details in [docs/performance.md](../../docs/performance.md). ## Not yet Zero-copy upload of decoded pictures into wgpu textures (`CVPixelBuffer`s on macOS, Direct3D 11-textures on Windows); B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in+textures on Windows; on Linux the VA-API half is in: `va::Display::export_surface` hands a decoded+surface over as a DMABUF - the plane layout, the objects' file descriptors and their format+modifiers, checked against the C layout in `abi_tests.rs` and on a real driver in `va.rs`'s test -+but nothing imports it into wgpu yet, which needs raw Vulkan (`wgpu-hal`) and a same-GPU rule);+B-frames in the VideoToolbox encoders and 10-bit / HDR HEVC (Main 10) in VideoToolbox; VP9 and AV1 through VA-API (Linux), and VA-API encoding; field-coded H.264; 4:4:4 and 4:2:2 encoding with NVENC, HDR H.264 with NVENC (HDR H.264 stays with the software encoder), HDR10 MaxCLL / MaxFALL measured from the pictures (they are written as 0, unknown), AV1 with NVENC, H.265 with the other Windows vendors and on Linux, and encoders@@ -9,11 +9,14 @@ use std::sync::Arc; use filmcraft_frame::{Chroma, PixelData, VideoFrame, pool}; -/// A mapped biplanar surface: a luma plane followed by interleaved chroma, `stride` bytes per row.+/// A mapped biplanar surface: a luma plane (`stride` bytes per row) and interleaved chroma+/// (`chroma_stride` bytes per row; the same as `stride` for most surfaces, but a derived VA-API+/// image may pitch its planes differently). pub struct Biplanar<'a> { pub luma: &'a [u8], pub chroma: &'a [u8], pub stride: usize,+ pub chroma_stride: usize, /// Rows in the luma plane (the surface height, which may exceed the picture's). pub rows: usize, }@@ -48,8 +51,12 @@ pub fn to_frame(src: &Biplanar, g: &Geometry) -> Result<VideoFrame, String> { let (cox, coy) = (ox / 2, oy / 2); let needs_rows = oy.checked_add(h).ok_or("picture size overflows")?; let needs_cols = ox.checked_add(w).and_then(|c| c.checked_mul(bps)).ok_or("picture size overflows")?;- if src.rows < needs_rows || src.stride < needs_cols || src.stride < (cox + cw) * 2 * bps {- return Err(format!("decoded surface ({} rows, {} byte rows) is smaller than the {w}x{h} picture at {ox},{oy}", src.rows, src.stride));+ let needs_chroma_cols = cox.checked_add(cw).and_then(|c| c.checked_mul(2 * bps)).ok_or("picture size overflows")?;+ if src.rows < needs_rows || src.stride < needs_cols || src.chroma_stride < needs_chroma_cols {+ return Err(format!(+ "decoded surface ({} rows, {} / {} byte rows) is smaller than the {w}x{h} picture at {ox},{oy}",+ src.rows, src.stride, src.chroma_stride+ )); } let data = if bps == 1 { let mut yp = pool::take_u8(w * h);@@ -59,7 +66,7 @@ pub fn to_frame(src: &Biplanar, g: &Geometry) -> Result<VideoFrame, String> { let (mut u, mut v) = (pool::take_u8(cw * ch), pool::take_u8(cw * ch)); for y in 0..ch { // two passes over the row: each is a simple strided copy the compiler vectorises- let pairs = row(src.chroma, src.stride, coy + y, cox * 2, cw * 2)?.as_chunks::<2>().0;+ let pairs = row(src.chroma, src.chroma_stride, coy + y, cox * 2, cw * 2)?.as_chunks::<2>().0; u.extend(pairs.iter().map(|p| p[0])); v.extend(pairs.iter().map(|p| p[1])); }@@ -72,7 +79,7 @@ pub fn to_frame(src: &Biplanar, g: &Geometry) -> Result<VideoFrame, String> { } let (mut u, mut v) = (pool::take_u16(cw * ch), pool::take_u16(cw * ch)); for y in 0..ch {- let quads = row(src.chroma, src.stride, coy + y, cox * 4, cw * 4)?.as_chunks::<4>().0;+ let quads = row(src.chroma, src.chroma_stride, coy + y, cox * 4, cw * 4)?.as_chunks::<4>().0; u.extend(quads.iter().map(|q| u16::from_le_bytes([q[0], q[1]]) >> shift)); v.extend(quads.iter().map(|q| u16::from_le_bytes([q[2], q[3]]) >> shift)); }@@ -95,7 +102,7 @@ mod tests { let (stride, rows) = (10usize, 6usize); let luma: Vec<u8> = (0..stride * rows).map(|i| i as u8).collect(); let chroma: Vec<u8> = (0..stride * rows / 2).map(|i| 100 + i as u8).collect();- let f = to_frame(&Biplanar { luma: &luma, chroma: &chroma, stride, rows }, &geometry((2, 2, 4, 4), 8)).unwrap();+ let f = to_frame(&Biplanar { luma: &luma, chroma: &chroma, stride, chroma_stride: stride, rows }, &geometry((2, 2, 4, 4), 8)).unwrap(); let PixelData::Yuv8 { planes, chroma: c, .. } = &f.data else { panic!("8-bit") }; assert_eq!(*c, Chroma::C420); assert_eq!(&planes[0][..], &[22, 23, 24, 25, 32, 33, 34, 35, 42, 43, 44, 45, 52, 53, 54, 55]);@@ -104,6 +111,21 @@ mod tests { assert_eq!(&planes[2][..], &[113, 115, 123, 125]); } + #[test]+ fn chroma_rows_use_their_own_pitch() {+ // a 4x4 picture: luma 4 bytes per row, chroma padded to 6 (a derived VA-API image can+ // pitch its planes differently)+ let luma: Vec<u8> = (0..16).collect();+ let chroma: Vec<u8> = vec![100, 101, 102, 103, 0, 0, 110, 111, 112, 113, 0, 0];+ let f = to_frame(&Biplanar { luma: &luma, chroma: &chroma, stride: 4, chroma_stride: 6, rows: 4 }, &geometry((0, 0, 4, 4), 8)).unwrap();+ let PixelData::Yuv8 { planes, .. } = &f.data else { panic!("8-bit") };+ assert_eq!(&planes[0][..], &luma[..]);+ assert_eq!(&planes[1][..], &[100, 102, 110, 112]);+ assert_eq!(&planes[2][..], &[101, 103, 111, 113]);+ // a chroma pitch too short for the picture is an error, not a short read+ assert!(to_frame(&Biplanar { luma: &luma, chroma: &chroma, stride: 4, chroma_stride: 3, rows: 4 }, &geometry((0, 0, 4, 4), 8)).is_err());+ }+ #[test] fn p010_shifts_down_from_the_high_bits() { let (stride, rows) = (8usize, 2usize);@@ -115,25 +137,46 @@ mod tests { for (i, s) in chroma.as_chunks_mut::<2>().0.iter_mut().enumerate() { *s = ((500 + i as u16) << 6).to_le_bytes(); }- let f = to_frame(&Biplanar { luma: &luma, chroma: &chroma, stride, rows }, &geometry((0, 0, 4, 2), 10)).unwrap();+ let f = to_frame(&Biplanar { luma: &luma, chroma: &chroma, stride, chroma_stride: stride, rows }, &geometry((0, 0, 4, 2), 10)).unwrap(); let PixelData::Yuv16 { planes, bits, .. } = &f.data else { panic!("16-bit") }; assert_eq!(*bits, 10); assert_eq!(&planes[0][..], &[1, 2, 3, 4, 5, 6, 7, 8]); assert_eq!((&planes[1][..], &planes[2][..]), (&[500, 502][..], &[501, 503][..])); } + #[test]+ fn p010_chroma_rows_use_their_own_pitch() {+ // a 4x4 P010 picture: luma 8 bytes per row, chroma 8 bytes padded to 12+ let (stride, chroma_stride, rows) = (8usize, 12usize, 4usize);+ let mut luma = vec![0u8; stride * rows];+ for (i, s) in luma.as_chunks_mut::<2>().0.iter_mut().enumerate() {+ *s = (((i as u16) + 1) << 6).to_le_bytes();+ }+ let mut chroma = vec![0u8; chroma_stride * 2];+ for row in 0..2 {+ for i in 0..4 {+ let at = row * chroma_stride + i * 2;+ chroma[at..at + 2].copy_from_slice(&((500 + row as u16 * 10 + i as u16) << 6).to_le_bytes());+ }+ }+ let f = to_frame(&Biplanar { luma: &luma, chroma: &chroma, stride, chroma_stride, rows }, &geometry((0, 0, 4, 4), 10)).unwrap();+ let PixelData::Yuv16 { planes, .. } = &f.data else { panic!("16-bit") };+ assert_eq!(&planes[0][..], &(1..=16).collect::<Vec<u16>>()[..]);+ assert_eq!((&planes[1][..], &planes[2][..]), (&[500, 502, 510, 512][..], &[501, 503, 511, 513][..]));+ }+ #[test] fn surfaces_smaller_than_the_picture_are_errors() { let luma = vec![0u8; 64]; let chroma = vec![0u8; 32];- let src = Biplanar { luma: &luma, chroma: &chroma, stride: 8, rows: 8 };+ let src = Biplanar { luma: &luma, chroma: &chroma, stride: 8, chroma_stride: 8, rows: 8 }; for crop in [(0, 0, 9, 8), (0, 0, 8, 9), (4, 0, 8, 8), (0, 0, 0, 8), (u32::MAX, 0, 8, 8), (0, 0, 8, 8)] { let r = to_frame(&src, &geometry(crop, 8)); assert_eq!(r.is_ok(), crop == (0, 0, 8, 8), "{crop:?}"); } assert!(to_frame(&src, &geometry((0, 0, 4, 4), 12)).is_err()); // a short chroma plane is an error, not a panic- let short = Biplanar { luma: &luma, chroma: &chroma[..8], stride: 8, rows: 8 };+ let short = Biplanar { luma: &luma, chroma: &chroma[..8], stride: 8, chroma_stride: 8, rows: 8 }; assert!(to_frame(&short, &geometry((0, 0, 8, 8), 8)).is_err()); } }@@ -261,7 +261,7 @@ impl Readback { // SAFETY: see the `Map` call: `luma_len + chroma_len` bytes are mapped at `pData`. let all = unsafe { std::slice::from_raw_parts(mapped.pData as *const u8, luma_len + chroma_len) }; let (luma, chroma) = all.split_at(luma_len);- f(&Biplanar { luma, chroma, stride, rows })+ f(&Biplanar { luma, chroma, stride, chroma_stride: stride, rows }) })(); // SAFETY: the subresource was mapped above and nothing borrowed from it outlives `result`. unsafe { gpu.context.Unmap(staging, 0) };@@ -238,7 +238,7 @@ impl State { 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)+ to_frame(&Biplanar { luma, chroma, stride: pitch as usize, 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> {@@ -363,3 +363,33 @@ fn bit_fields_match_the_header() { assert_eq!(1u32 << hevc_slice_bits::COLLOCATED_FROM_L0, 0x1000, "LongSliceFlags.collocated_from_l0_flag"); assert_eq!(1u32 << hevc_slice_bits::SLICE_LOOP_FILTER_ACROSS_SLICES_ENABLED, 0x2000, "LongSliceFlags.slice_loop_filter_across_slices_enabled_flag"); }++/// The DRM PRIME 2 surface export structures and constants (`va_drmcommon.h`, `va.h`), printed by+/// the same C program as the layouts above.+#[test]+fn drm_prime_export_matches_the_header() {+ assert_eq!(VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2 as i64, 0x40000000, "VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2");+ assert_eq!(VA_EXPORT_SURFACE_READ_ONLY as i64, 0x0001, "VA_EXPORT_SURFACE_READ_ONLY");+ assert_eq!(VA_EXPORT_SURFACE_READ_WRITE as i64, 0x0003, "VA_EXPORT_SURFACE_READ_WRITE");+ assert_eq!(VA_EXPORT_SURFACE_SEPARATE_LAYERS as i64, 0x0004, "VA_EXPORT_SURFACE_SEPARATE_LAYERS");+ assert_eq!(VA_FOURCC_NV12, 0x3231564E, "VA_FOURCC_NV12");+ assert_eq!(VA_FOURCC_P010, 0x30313050, "VA_FOURCC_P010");+ assert_eq!((size_of::<VADRMPRIMEObject>(), align_of::<VADRMPRIMEObject>()), (16, 8), "VADRMPRIMEObject");+ assert_eq!(offset_of!(VADRMPRIMEObject, fd), 0, "object.fd");+ assert_eq!(offset_of!(VADRMPRIMEObject, size), 4, "object.size");+ assert_eq!(offset_of!(VADRMPRIMEObject, drm_format_modifier), 8, "object.drm_format_modifier");+ assert_eq!((size_of::<VADRMPRIMESurfaceLayer>(), align_of::<VADRMPRIMESurfaceLayer>()), (56, 4), "VADRMPRIMESurfaceLayer");+ assert_eq!(offset_of!(VADRMPRIMESurfaceLayer, drm_format), 0, "layer.drm_format");+ assert_eq!(offset_of!(VADRMPRIMESurfaceLayer, num_planes), 4, "layer.num_planes");+ assert_eq!(offset_of!(VADRMPRIMESurfaceLayer, object_index), 8, "layer.object_index");+ assert_eq!(offset_of!(VADRMPRIMESurfaceLayer, offset), 24, "layer.offset");+ assert_eq!(offset_of!(VADRMPRIMESurfaceLayer, pitch), 40, "layer.pitch");+ assert_eq!((size_of::<VADRMPRIMESurfaceDescriptor>(), align_of::<VADRMPRIMESurfaceDescriptor>()), (312, 8), "VADRMPRIMESurfaceDescriptor");+ assert_eq!(offset_of!(VADRMPRIMESurfaceDescriptor, fourcc), 0, "descriptor.fourcc");+ assert_eq!(offset_of!(VADRMPRIMESurfaceDescriptor, width), 4, "descriptor.width");+ assert_eq!(offset_of!(VADRMPRIMESurfaceDescriptor, height), 8, "descriptor.height");+ assert_eq!(offset_of!(VADRMPRIMESurfaceDescriptor, num_objects), 12, "descriptor.num_objects");+ assert_eq!(offset_of!(VADRMPRIMESurfaceDescriptor, objects), 16, "descriptor.objects");+ assert_eq!(offset_of!(VADRMPRIMESurfaceDescriptor, num_layers), 80, "descriptor.num_layers");+ assert_eq!(offset_of!(VADRMPRIMESurfaceDescriptor, layers), 84, "descriptor.layers");+}@@ -498,6 +498,7 @@ pub type vaEndPicture = unsafe extern "C" fn(dpy: VADisplay, context: VAContextI pub type vaSyncSurface = unsafe extern "C" fn(dpy: VADisplay, render_target: VASurfaceID) -> VAStatus; pub type vaCreateImage = unsafe extern "C" fn(dpy: VADisplay, format: *mut VAImageFormat, width: c_int, height: c_int, image: *mut VAImage) -> VAStatus; pub type vaDestroyImage = unsafe extern "C" fn(dpy: VADisplay, image: VAImageID) -> VAStatus;+pub type vaDeriveImage = unsafe extern "C" fn(dpy: VADisplay, surface: VASurfaceID, image: *mut VAImage) -> VAStatus; pub type vaGetImage = unsafe extern "C" fn(dpy: VADisplay, surface: VASurfaceID, x: c_int, y: c_int, width: c_uint, height: c_uint, image: VAImageID) -> VAStatus; pub type vaPutImage = unsafe extern "C" fn(@@ -517,3 +518,54 @@ pub type vaMapBuffer = unsafe extern "C" fn(dpy: VADisplay, buf_id: VABufferID, pub type vaUnmapBuffer = unsafe extern "C" fn(dpy: VADisplay, buf_id: VABufferID) -> VAStatus; /// `libva-drm.so.2`: the display of a DRM device (render node) file descriptor. pub type vaGetDisplayDRM = unsafe extern "C" fn(fd: c_int) -> VADisplay;++// ---- DRM PRIME 2 surface export (va_drmcommon.h, va.h): the decoded picture as DMABUF objects,+/// which a GPU importer (`VK_EXT_external_memory_dma_buf`) can take without a readback.+///+/// `VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2`: export a surface's underlying DMABUF objects.+pub const VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2: c_uint = 0x4000_0000;+/// `VA_EXPORT_SURFACE_READ_ONLY`: the exported objects are read (`READ_WRITE` when we may write).+pub const VA_EXPORT_SURFACE_READ_ONLY: c_uint = 0x0001;+/// `VA_EXPORT_SURFACE_READ_WRITE`: the decoder wrote them, the importer only reads.+pub const VA_EXPORT_SURFACE_READ_WRITE: c_uint = 0x0003;+/// `VA_EXPORT_SURFACE_SEPARATE_LAYERS`: one layer per plane (NV12: two, P010: two).+pub const VA_EXPORT_SURFACE_SEPARATE_LAYERS: c_uint = 0x0004;++/// One DMABUF object of an exported surface: a file descriptor the caller owns (libva does not+/// close it), its size and the DRM format modifier the driver decoded with.+#[repr(C)]+#[derive(Clone, Copy, Debug, Default)]+pub struct VADRMPRIMEObject {+ pub fd: c_int,+ pub size: u32,+ pub drm_format_modifier: u64,+}++/// One layer of an exported surface: its DRM format and, per plane, which object holds it, the+/// offset inside that object and the pitch in bytes.+#[repr(C)]+#[derive(Clone, Copy, Debug, Default)]+pub struct VADRMPRIMESurfaceLayer {+ pub drm_format: u32,+ pub num_planes: u32,+ pub object_index: [u32; 4],+ pub offset: [u32; 4],+ pub pitch: [u32; 4],+}++/// `VADRMPRIMESurfaceDescriptor` (va_drmcommon.h): what `vaExportSurfaceHandle` fills for a+/// surface, up to four objects and four layers.+#[repr(C)]+#[derive(Clone, Copy)]+pub struct VADRMPRIMESurfaceDescriptor {+ /// Pixel format fourcc of the whole surface (`VA_FOURCC_*`).+ pub fourcc: u32,+ pub width: u32,+ pub height: u32,+ pub num_objects: u32,+ pub objects: [VADRMPRIMEObject; 4],+ pub num_layers: u32,+ pub layers: [VADRMPRIMESurfaceLayer; 4],+}+pub type vaExportSurfaceHandle =+ unsafe extern "C" fn(dpy: VADisplay, surface: VASurfaceID, mem_type: c_uint, flags: c_uint, descriptor: *mut VADRMPRIMESurfaceDescriptor) -> VAStatus;@@ -30,7 +30,7 @@ struct Recorder { impl Recorder { fn new(count: usize) -> Self { let g = Geometry { crop: (0, 0, 2, 2), bits: 8, color: Default::default(), par: (1, 1) };- let frame = biplanar::to_frame(&Biplanar { luma: &[128; 4], chroma: &[128; 2], stride: 2, rows: 2 }, &g).unwrap();+ let frame = biplanar::to_frame(&Biplanar { luma: &[128; 4], chroma: &[128; 2], stride: 2, chroma_stride: 2, rows: 2 }, &g).unwrap(); Self { ids: (0..count as u32).map(|i| 0x100 + i).collect(), decoded: vec![false; count], pictures: 0, frame } } }@@ -3,13 +3,19 @@ //! (decode configuration, context and surfaces for one stream) decodes pictures and reads them back //! as NV12 images. //!+//! A decoded surface can also be handed over as DMABUF (`vaExportSurfaceHandle`, DRM PRIME 2) with+//! [`Display::export_surface`]: the plane layout and the objects' file descriptors, for a compositor+//! that imports them as a texture (no readback). The import itself is not here yet: wgpu's safe API+//! has no external-memory import, so it needs raw Vulkan (`wgpu-hal`) plus the same-GPU rule and a+//! fallback to the readback.+//! //! FFI module (docs/adr/0001-platform-ffi.md): every `unsafe` block has a `// SAFETY:` comment, the //! public API is safe, and the one callback into Rust (libva's error messages) runs under //! `catch_unwind`. use std::ffi::{CStr, c_char, c_int, c_uint, c_void}; use std::fs::{File, OpenOptions};-use std::os::fd::AsRawFd;+use std::os::fd::{AsRawFd, FromRawFd, OwnedFd}; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::{Arc, Mutex, OnceLock, PoisonError}; @@ -46,10 +52,14 @@ struct Api { create_image: ffi::vaCreateImage, destroy_image: ffi::vaDestroyImage, get_image: ffi::vaGetImage,+ /// Maps a surface's own memory as an image (no copy into a second image); optional.+ derive_image: Option<ffi::vaDeriveImage>, put_image: ffi::vaPutImage, map_buffer: ffi::vaMapBuffer, unmap_buffer: ffi::vaUnmapBuffer, get_display_drm: ffi::vaGetDisplayDRM,+ /// DMABUF export; None on a libva older than 2.1 (the readback stays the only path there).+ export_surface_handle: Option<ffi::vaExportSurfaceHandle>, } static API: OnceLock<Result<Api, String>> = OnceLock::new();@@ -73,6 +83,14 @@ fn load() -> Result<Api, String> { *s.map_err(|e| format!("{}: {e}", stringify!($name)))? }}; }+ // A symbol an older libva may not have: absent is not an error (the caller declines).+ macro_rules! sym_opt {+ ($lib:expr, $name:ident) => {{+ // SAFETY: as `sym!`, but the pointer is kept only when the symbol resolves.+ let s = unsafe { $lib.get::<ffi::$name>(concat!(stringify!($name), "\0").as_bytes()) };+ s.ok().map(|s| *s)+ }};+ } Ok(Api { initialize: sym!(va, vaInitialize), terminate: sym!(va, vaTerminate),@@ -96,10 +114,12 @@ fn load() -> Result<Api, String> { create_image: sym!(va, vaCreateImage), destroy_image: sym!(va, vaDestroyImage), get_image: sym!(va, vaGetImage),+ derive_image: sym_opt!(va, vaDeriveImage), put_image: sym!(va, vaPutImage), map_buffer: sym!(va, vaMapBuffer), unmap_buffer: sym!(va, vaUnmapBuffer), get_display_drm: sym!(drm, vaGetDisplayDRM),+ export_surface_handle: sym_opt!(va, vaExportSurfaceHandle), _va: va, _drm: drm, })@@ -221,6 +241,89 @@ impl Display { &self.vendor } + /// Make `count` decode surfaces of `rt_format` at `size` (no attributes). The caller destroys+ /// them with [`destroy_surfaces`](Self::destroy_surfaces) or hands them to a session.+ pub fn create_surfaces(&self, rt_format: c_uint, size: (u32, u32), count: usize) -> Result<Vec<VASurfaceID>, String> {+ let count_c = c_uint::try_from(count).map_err(|_| "too many surfaces")?;+ let mut surfaces = vec![ffi::VA_INVALID_SURFACE; count];+ // SAFETY: `surfaces` has room for `count` ids; `self.dpy` is an initialised display; no+ // attributes are passed (null, 0), so the driver picks the layout.+ let st = unsafe { (self.api.create_surfaces)(self.dpy, rt_format, size.0, size.1, surfaces.as_mut_ptr(), count_c, std::ptr::null_mut(), 0) };+ self.check(st, "vaCreateSurfaces")?;+ Ok(surfaces)+ }++ /// Destroy surfaces made by [`create_surfaces`](Self::create_surfaces).+ pub fn destroy_surfaces(&self, surfaces: &mut [VASurfaceID]) -> Result<(), String> {+ if surfaces.is_empty() {+ return Ok(());+ }+ let count = c_int::try_from(surfaces.len()).map_err(|_| "too many surfaces")?;+ // SAFETY: `surfaces` is a live slice of ids this display made, `count` its length.+ let st = unsafe { (self.api.destroy_surfaces)(self.dpy, surfaces.as_mut_ptr(), count) };+ self.check(st, "vaDestroySurfaces")?;+ surfaces.fill(ffi::VA_INVALID_SURFACE);+ Ok(())+ }++ /// Export `surface` as DMABUF (DRM PRIME 2, one layer per plane): the plane layout - format,+ /// object, offset, pitch - and the objects' file descriptors, which the caller owns and which+ /// are closed when the returned value drops. This is what a GPU importer+ /// (`VK_EXT_external_memory_dma_buf` + `VK_EXT_image_drm_format_modifier`) binds as a texture+ /// where the compositor works, instead of a readback. The surface is synchronised first+ /// (`vaSyncSurface`; the export itself does not wait), so the picture is complete when the+ /// importer reads it. `Err` when libva has no `vaExportSurfaceHandle`, the driver refuses the+ /// surface, or what it describes is inconsistent.+ pub fn export_surface(&self, surface: VASurfaceID) -> Result<SurfaceDmabuf, String> {+ let export = self.api.export_surface_handle.ok_or("libva has no vaExportSurfaceHandle (needs libva 2.1)")?;+ // SAFETY: `surface` is an id this display made; vaSyncSurface only waits for its work.+ let st = unsafe { (self.api.sync_surface)(self.dpy, surface) };+ self.check(st, "vaSyncSurface")?;+ // SAFETY: every field is a plain integer, an array of integers or (below) a file+ // descriptor libva fills in; an all-zero descriptor is "nothing exported" and is checked+ // before anything is read from it.+ let mut d: ffi::VADRMPRIMESurfaceDescriptor = unsafe { std::mem::zeroed() };+ // one layer per plane, read-only: the decoder wrote the surface, the importer only reads it+ let flags = ffi::VA_EXPORT_SURFACE_READ_ONLY | ffi::VA_EXPORT_SURFACE_SEPARATE_LAYERS;+ // SAFETY: `self.dpy` is an initialised display, `surface` an id this display made, and `d`+ // is a live descriptor libva fills in. The driver creates the file descriptors it lists and+ // hands them to the caller (va_drmcommon.h: the driver does not close them).+ let st = unsafe { export(self.dpy, surface, ffi::VA_SURFACE_ATTRIB_MEM_TYPE_DRM_PRIME_2, flags, &mut d) };+ self.check(st, "vaExportSurfaceHandle")?;+ // What the driver describes is input-derived: check the counts before they are used as+ // lengths, and every index before it indexes an object.+ let objects = (d.num_objects as usize).min(d.objects.len());+ let layers = (d.num_layers as usize).min(d.layers.len());+ // Own every descriptor the driver handed over before checking anything, so an error below+ // closes them all instead of leaking the ones not yet looked at.+ // SAFETY: libva hands each listed descriptor to the caller (it does not close it), so this+ // takes ownership: each is closed exactly once, when its `OwnedFd` is dropped.+ let fds: Vec<Option<OwnedFd>> = d.objects.iter().take(objects).map(|o| (o.fd >= 0).then(|| unsafe { OwnedFd::from_raw_fd(o.fd) })).collect();+ if objects == 0 {+ return Err("vaExportSurfaceHandle described no objects".into());+ }+ let mut out_objects = Vec::with_capacity(objects);+ for (o, fd) in d.objects.iter().zip(fds) {+ let fd = fd.ok_or("vaExportSurfaceHandle described an invalid file descriptor")?;+ out_objects.push(DmabufObject { fd, size: o.size, modifier: o.drm_format_modifier });+ }+ let mut planes = Vec::new();+ for l in d.layers.iter().take(layers) {+ let n = l.num_planes as usize;+ for (p, ((&object, &offset), &pitch)) in l.object_index.iter().zip(&l.offset).zip(&l.pitch).take(n).enumerate() {+ let object = object as usize;+ if object >= objects {+ return Err(format!("vaExportSurfaceHandle described plane {p} in object {object} of {objects}"));+ }+ planes.push(DmabufPlane { format: l.drm_format, object, offset, pitch });+ }+ }+ if planes.is_empty() {+ return Err("vaExportSurfaceHandle described no planes".into());+ }+ Ok(SurfaceDmabuf { fourcc: d.fourcc, width: d.width, height: d.height, objects: out_objects, planes })+ }+ fn check(&self, st: ffi::VAStatus, what: &str) -> Result<(), String> { if st == ffi::VA_STATUS_SUCCESS { return Ok(());@@ -254,6 +357,8 @@ pub struct Session { context: ffi::VAContextID, surfaces: Vec<VASurfaceID>, image: Option<VAImage>,+ /// Whether to read pictures through `vaDeriveImage` (cleared the first time it fails).+ derive: bool, /// Coded size of the surfaces (luma samples). size: (u32, u32), geometry: Geometry,@@ -321,12 +426,8 @@ impl Session { } let config = config.ok_or(why)?; // From here on `Drop` releases what was made.- let mut s = Self { display, config, context: ffi::VA_INVALID_ID, surfaces: Vec::new(), image: None, size, geometry };- let count_c = c_uint::try_from(count).map_err(|_| "too many surfaces")?;- let mut surfaces = vec![ffi::VA_INVALID_SURFACE; count];- // SAFETY: `surfaces` has room for `count` ids; no attributes are passed (null, 0).- let st = unsafe { (api.create_surfaces)(dpy, rt_format, size.0, size.1, surfaces.as_mut_ptr(), count_c, std::ptr::null_mut(), 0) };- s.display.check(st, "vaCreateSurfaces")?;+ let mut s = Self { display, config, context: ffi::VA_INVALID_ID, surfaces: Vec::new(), image: None, derive: true, size, geometry };+ let surfaces = s.display.create_surfaces(rt_format, size, count)?; s.surfaces = surfaces; let (w, h) = (c_int::try_from(size.0).map_err(|_| "picture too wide")?, c_int::try_from(size.1).map_err(|_| "picture too tall")?); let n = c_int::try_from(count).map_err(|_| "too many surfaces")?;@@ -431,6 +532,30 @@ impl Session { Ok(image) } + /// The picture in surface `index`, read through an image derived from the surface itself:+ /// the driver maps the decoded memory instead of copying it into a second image first, which+ /// is most of `vaGetImage`'s cost at 4K. An error leaves the caller to use `vaGetImage`.+ fn read_derived(&mut self, index: usize) -> Result<VideoFrame, String> {+ let derive = self.display.api.derive_image.ok_or("libva has no vaDeriveImage")?;+ let surface = *self.surfaces.get(index).ok_or("surface out of range")?;+ let api = self.display.api;+ // SAFETY: `surface` is one of this session's surfaces.+ let st = unsafe { (api.sync_surface)(self.display.dpy, surface) };+ self.display.check(st, "vaSyncSurface")?;+ let mut image = VAImage { image_id: ffi::VA_INVALID_ID, buf: ffi::VA_INVALID_ID, ..Default::default() };+ // SAFETY: `surface` was made on this display; `image` is a live output the driver fills in.+ let st = unsafe { derive(self.display.dpy, surface, &mut image) };+ self.display.check(st, "vaDeriveImage")?;+ let frame = self.read_image(&image);+ // SAFETY: the image was derived above on this display and is destroyed once; its buffer+ // has been unmapped by `read_image`.+ let st = unsafe { (api.destroy_image)(self.display.dpy, image.image_id) };+ // a derived image the driver cannot free would pile up: stop deriving (the caller falls+ // back to vaGetImage)+ self.display.check(st, "vaDestroyImage (derived image)")?;+ frame+ }+ /// Every sample of `image` set to mid-gray: 128 in NV12, 512 (0x8000 in the high bits of /// little-endian 16-bit words) in P010. fn write_gray(&self, image: &VAImage) -> Result<(), String> {@@ -497,12 +622,9 @@ fn biplanar_view<'a>(image: &VAImage, data: &'a [u8]) -> Result<Biplanar<'a>, St return Err(format!("the read-back image is not NV12 / P010 (fourcc {:#x}, {} planes)", image.format.fourcc, image.num_planes)); } let [p0, p1, _] = image.pitches;- if p0 != p1 {- return Err(format!("NV12 planes with different pitches ({p0}, {p1})"));- } let luma = data.get(image.offsets[0] as usize..).ok_or("NV12 luma offset out of range")?; let chroma = data.get(image.offsets[1] as usize..).ok_or("NV12 chroma offset out of range")?;- Ok(Biplanar { luma, chroma, stride: p0 as usize, rows: usize::from(image.height) })+ Ok(Biplanar { luma, chroma, stride: p0 as usize, chroma_stride: p1 as usize, rows: usize::from(image.height) }) } impl Accel for Session {@@ -531,6 +653,15 @@ impl Accel for Session { } fn read(&mut self, index: usize) -> Result<VideoFrame, String> {+ if self.derive {+ match self.read_derived(index) {+ Ok(frame) => return Ok(frame),+ Err(e) => {+ log::info!("VA-API: reading pictures with vaGetImage ({e})");+ self.derive = false;+ }+ }+ } let image = self.get_image(index)?; self.read_image(&image) }@@ -576,6 +707,43 @@ impl Drop for Session { } } +/// One DMABUF object of an exported surface: its file descriptor (owned: closed on drop), its size+/// in bytes and the DRM format modifier the driver decoded the surface with.+#[derive(Debug)]+pub struct DmabufObject {+ pub fd: OwnedFd,+ pub size: u32,+ pub modifier: u64,+}++/// One plane of an exported surface: the DRM format, which object holds it, the offset inside that+/// object and the pitch in bytes.+#[derive(Clone, Copy, Debug, PartialEq, Eq)]+pub struct DmabufPlane {+ pub format: u32,+ pub object: usize,+ pub offset: u32,+ pub pitch: u32,+}++/// A decoded surface as DMABUF ([`Display::export_surface`]): what a GPU importer needs to bind the+/// picture as a texture with no GPU → CPU readback. The objects' file descriptors are owned here.+#[derive(Debug)]+pub struct SurfaceDmabuf {+ /// `VA_FOURCC_*` of the whole surface (NV12, P010, …).+ pub fourcc: u32,+ pub width: u32,+ pub height: u32,+ pub objects: Vec<DmabufObject>,+ pub planes: Vec<DmabufPlane>,+}++/// Whether the loaded libva can export a surface as DMABUF (`vaExportSurfaceHandle`, libva 2.1):+/// the prerequisite of a zero-copy GPU path. Checked without opening a display.+pub fn can_export_surface() -> bool {+ matches!(api(), Ok(a) if a.export_surface_handle.is_some())+}+ /// Whether this system has a working VA-API driver on a DRM render node (checked once), with the /// driver's description, or why not. pub fn probe() -> &'static Result<String, String> {@@ -583,3 +751,62 @@ pub fn probe() -> &'static Result<String, String> { // the display opened to answer is the one sessions share PROBE.get_or_init(|| shared().map(|d| d.vendor().to_string())) }++#[cfg(test)]+mod tests {+ use super::*;++ /// The DMABUF export describes a decoded surface's planes and hands over its objects: the first+ /// half of the zero-copy path, checked on the real driver. Skips without one (and without+ /// `vaExportSurfaceHandle`).+ #[test]+ fn surface_export_describes_the_planes() {+ let Ok(vendor) = probe() else {+ eprintln!("no VA-API driver, skipping");+ return;+ };+ let Ok(d) = shared() else {+ eprintln!("no shared display, skipping");+ return;+ };+ if !can_export_surface() {+ eprintln!("{vendor}: libva without vaExportSurfaceHandle, skipping");+ return;+ }+ // 8-bit 4:2:0: NV12, two planes (luma, interleaved chroma), at least one byte per sample+ let mut s8 = d.create_surfaces(ffi::VA_RT_FORMAT_YUV420, (64, 64), 1).unwrap();+ {+ let e = d.export_surface(s8[0]).unwrap();+ assert_eq!(e.fourcc, ffi::VA_FOURCC_NV12, "{e:?}");+ assert_eq!((e.width, e.height), (64, 64));+ assert_eq!(e.planes.len(), 2, "{e:?}");+ assert!(e.planes.iter().all(|p| p.pitch >= 64), "{e:?}");+ assert!(e.objects.iter().all(|o| o.size > 0), "{e:?}");+ // the descriptors are real dmabufs: they fstat like any file+ for o in &e.objects {+ let f = File::from(o.fd.try_clone().unwrap());+ assert!(f.metadata().unwrap().len() > 0, "{e:?}");+ }+ }+ d.destroy_surfaces(&mut s8).unwrap();+ // 10-bit 4:2:0: a 10-bit format (P010 or wider), still two planes+ let mut s10 = match d.create_surfaces(ffi::VA_RT_FORMAT_YUV420_10, (64, 64), 1) {+ Ok(s) => s,+ // a driver without 10-bit 4:2:0 surfaces: the 8-bit half above is the whole test+ Err(e) if e.ends_with(&format!("({:#x})", ffi::VA_STATUS_ERROR_UNSUPPORTED_RT_FORMAT)) => {+ eprintln!("{vendor}: no 10-bit 4:2:0 surfaces ({e}), skipping the 10-bit export");+ return;+ }+ Err(e) => panic!("{vendor}: 10-bit surfaces: {e}"),+ };+ {+ let e = d.export_surface(s10[0]).unwrap();+ assert_ne!(e.fourcc, ffi::VA_FOURCC_NV12, "10-bit surface as 8-bit: {e:?}");+ assert_eq!(e.planes.len(), 2, "{e:?}");+ assert!(e.planes.iter().all(|p| p.pitch >= 128), "10-bit pitch in bytes: {e:?}");+ assert!(e.objects.iter().all(|o| o.size > 0), "{e:?}");+ eprintln!("{vendor}: 8-bit NV12 and 10-bit 0x{:08x} exported over DMABUF", e.fourcc);+ }+ d.destroy_surfaces(&mut s10).unwrap();+ }+}@@ -157,3 +157,14 @@ VA-API's. The rules above hold: `libnvcuvid.so.1` and `libcuda.so.1` are loaded 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).++## Addendum (2026-10-24): VA-API surface export as DMABUF (Linux)++The first half of zero-copy playback on Linux adds no `unsafe` module: `vaapi::va` gained+`Display::export_surface` (`vaExportSurfaceHandle`, DRM PRIME 2). The declarations are transcribed+from libva's MIT-licensed `va.h` / `va_drmcommon.h` and checked in `vaapi/abi_tests.rs`, as before.+The driver hands over file descriptors; every one is owned (`OwnedFd`) before anything else is+checked, so an error closes them all and none leaks. The driver's counts are clamped to the arrays+and every plane's object index is checked. The public result (`SurfaceDmabuf`) is plain data and+owned descriptors, no raw pointers. Nothing imports it yet, so the readback stays the only path and+the fallback guarantee is unchanged.@@ -1,6 +1,6 @@ # Hardware parity -> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-10 · **Change:** major (new file: hardware decode / encode per platform and codec, GPU effects, devices; re-measured after NVDEC #549 and GPU export #421) · **Target:** Adobe Premiere Pro 2026 (26.5.2)+> **Last reviewed:** 2026-10-10 · **Last updated:** 2026-10-24 · **Change:** minor (VA-API read-back through vaDeriveImage) · **Target:** Adobe Premiere Pro 2026 (26.5.2) Each hardware feature Premiere uses, per platform, against ours. Implementation detail lives in [`crates/platform/README.md`](../crates/platform/README.md) (decode / encode backends, the one@@ -27,7 +27,9 @@ release notes (Premiere does not run on Linux). Measured (`cargo xtask bench --hw off|auto`): M4 Pro, CPU per 2160p frame H.264 119 → 4.2 ms, HEVC 86 → 3.7 ms, 4K plays with no dropped frames at Full; Intel Iris Xe VA-API H.264 2160p-417 → 11.4 ms, HEVC Main 10 2160p 212 → 18.4 ms. Every decoded picture is read back to the CPU+417 → 11.4 ms, HEVC Main 10 2160p 212 → 18.4 ms. VA-API now reads pictures through `vaDeriveImage` where libva and the driver allow it (else `vaGetImage`): 4K HEVC+Main 10 decode 35 → 66–68 fps on an RX 9070 XT (measured during development, not with the bench;+[performance.md](performance.md#va-api-read-back-through-vaderiveimage)). Every decoded picture is read back to the CPU and re-uploaded to wgpu (**no zero-copy** yet), which Premiere avoids. ## Hardware encode@@ -85,3 +87,4 @@ one encoder ~4–8 h ([target-app-parity.md](target-app-parity.md#calibration)). | Date | Change | Summary | |---|---|---| | 2026-10-10 | major | Created from ROADMAP.md's performance-and-hardware row and `crates/platform/README.md`; added Linux NVDEC (#549), GPU export and Lumetri on the GPU (#421), Premiere's per-platform hardware list, devices and I/O |+| 2026-10-24 | minor | VA-API read-back through vaDeriveImage |@@ -495,10 +495,20 @@ software either way and show the run-to-run spread. The decoded pictures are ide What is left on the CPU per H.264 frame is the host side of stateless decoding (parsing, DPB, filling the VA buffers) and the read-back: `vaGetImage` into an NV12 image and the copy into-planar Y'CbCr. One picture is decoded at a time and read back as soon as the DPB outputs it, so+planar Y'CbCr (since then `vaDeriveImage`, below). One picture is decoded at a time and read back as soon as the DPB outputs it, so 4K throughput (56 fps) is bound by that round trip, not by the video engine; overlapping decode and read-back, or zero-copy into wgpu, would raise it. +### VA-API read-back through `vaDeriveImage`++The read-back maps the decoded surface's own NV12 / P010 memory (`vaDeriveImage`) instead of+copying it into a second image with `vaGetImage` first; a driver that cannot derive a surface+switches the session back to `vaGetImage` (logged once). Sequential decode of a 4K50 HEVC Main 10+file on an AMD Radeon RX 9070 XT (Mesa 26.2.5, radeonsi), before and after the change: 35 →+66–68 fps, pictures bit-identical to the software decoder. *Measured during development; the load+average was not recorded and it was not run through `cargo xtask bench`*, so treat it as an+indication until the bench table is re-measured.+ ### HEVC through VA-API (HW5 follow-up) Same machine and commands on 2026-10-09 with HEVC added, at a lower load (the software decoders ran