Browse / github.com/storytold/filmcraft / patch
platform: probe whether a VA-API DMABUF can be imported where wgpu draws (Linux)
platform: probe whether a VA-API DMABUF can be imported where wgpu draws (Linux) Depends on #3222 (and through it #3214): this branch contains both commits. Only the last commit (3750b70e) belongs to this PR; merge #3214 and #3222 first. ## What Before taking a zero-copy path the compositor must know whether an imported image can be made at all: the importing Vulkan device has to be the decoder's GPU and take the memory and plane layout the VA driver produced. - vulkan (new, Linux FFI module): libvulkan.so.1 is loaded at run time and an instance is created once, only to enumerate the physical devices. For each it reads the name and API version, its device extensions and
- Status
- open
- Score
- 0.00
- Adopters
- 0 other operators
- Author
- zsoli69 (imported from pull request #3223, open)
- Base
10d626ccc4888e0f020f9ca314c2bc9148ded4f4- Size
- +993 −30 in 15 files
- Review
- pending
- Shared
- 2026-10-28
- Id
9f18108afb668a69f67c1935
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/lib.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 crates/platform/src/vulkan/abi_tests.rs crates/platform/src/vulkan/ffi.rs crates/platform/src/vulkan/mod.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,15 @@ 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;+`vulkan::import_capability` answers that same-GPU question - it enumerates the Vulkan devices once+and says which of them is the VA display's own DRM render node (`VK_EXT_physical_device_drm`) and+takes DMABUF memory in the VA driver's plane layout (`VK_EXT_external_memory_dma_buf` ++`VK_EXT_image_drm_format_modifier`), so the compositor can decline before it tries);+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()); } }@@ -52,6 +52,10 @@ pub mod videotoolbox; #[cfg(target_os = "macos")] #[allow(unsafe_code)] pub mod videotoolbox_encode;+/// Vulkan: the DMABUF import probe for the zero-copy path (Linux).+#[cfg(target_os = "linux")]+#[allow(unsafe_code)]+pub mod vulkan; pub use hybrid::HybridDecoder; @@ -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,101 @@ impl Display { &self.vendor } + /// The DRM render node the display decodes on, `(major, minor)`: what a Vulkan device describes+ /// itself with (`VK_EXT_physical_device_drm`), so a DMABUF import can tell whether the two are+ /// the same GPU.+ pub fn render_node(&self) -> Option<(u32, u32)> {+ use std::os::unix::fs::MetadataExt;+ let rdev = self._device.metadata().ok()?.rdev();+ // glibc's `major` / `minor` (makedev/major/minor), without a libc dependency+ let major = ((rdev >> 8) & 0xfff) | ((rdev >> 32) & !0xfff);+ let minor = (rdev & 0xff) | ((rdev >> 12) & !0xff);+ Some((u32::try_from(major).ok()?, u32::try_from(minor).ok()?))+ }++ /// 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 +369,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 +438,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 +544,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 +634,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 +665,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 +719,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 +763,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();+ }+}new file mode 100644@@ -0,0 +1,66 @@+//! The Vulkan FFI structures and constants against the C compiler's view of `vulkan.h` (Vulkan+//! 1.4.363): sizes, alignments, field offsets and the numbers of the structures the import probe+//! uses. Generated by running a C program (GCC against the Vulkan headers, x86-64 Linux) that+//! prints `sizeof` / `offsetof` of them; the header itself is not in this repository.+#![allow(clippy::unreadable_literal)]++use super::ffi::*;+use std::mem::{align_of, offset_of, size_of};++#[test]+fn layouts_match_the_header() {+ assert_eq!((size_of::<VkApplicationInfo>(), align_of::<VkApplicationInfo>()), (48, 8), "VkApplicationInfo");+ assert_eq!(offset_of!(VkApplicationInfo, s_type), 0, "VkApplicationInfo.sType");+ assert_eq!(offset_of!(VkApplicationInfo, p_next), 8, "VkApplicationInfo.pNext");+ assert_eq!(offset_of!(VkApplicationInfo, p_application_name), 16, "VkApplicationInfo.pApplicationName");+ assert_eq!(offset_of!(VkApplicationInfo, application_version), 24, "VkApplicationInfo.applicationVersion");+ assert_eq!(offset_of!(VkApplicationInfo, p_engine_name), 32, "VkApplicationInfo.pEngineName");+ assert_eq!(offset_of!(VkApplicationInfo, engine_version), 40, "VkApplicationInfo.engineVersion");+ assert_eq!(offset_of!(VkApplicationInfo, api_version), 44, "VkApplicationInfo.apiVersion");+ assert_eq!((size_of::<VkInstanceCreateInfo>(), align_of::<VkInstanceCreateInfo>()), (64, 8), "VkInstanceCreateInfo");+ assert_eq!(offset_of!(VkInstanceCreateInfo, s_type), 0, "VkInstanceCreateInfo.sType");+ assert_eq!(offset_of!(VkInstanceCreateInfo, p_next), 8, "VkInstanceCreateInfo.pNext");+ assert_eq!(offset_of!(VkInstanceCreateInfo, flags), 16, "VkInstanceCreateInfo.flags");+ assert_eq!(offset_of!(VkInstanceCreateInfo, p_application_info), 24, "VkInstanceCreateInfo.pApplicationInfo");+ assert_eq!(offset_of!(VkInstanceCreateInfo, enabled_layer_count), 32, "VkInstanceCreateInfo.enabledLayerCount");+ assert_eq!(offset_of!(VkInstanceCreateInfo, pp_enabled_layer_names), 40, "VkInstanceCreateInfo.ppEnabledLayerNames");+ assert_eq!(offset_of!(VkInstanceCreateInfo, enabled_extension_count), 48, "VkInstanceCreateInfo.enabledExtensionCount");+ assert_eq!(offset_of!(VkInstanceCreateInfo, pp_enabled_extension_names), 56, "VkInstanceCreateInfo.ppEnabledExtensionNames");+ assert_eq!((size_of::<VkPhysicalDeviceProperties>(), align_of::<VkPhysicalDeviceProperties>()), (824, 8), "VkPhysicalDeviceProperties");+ assert_eq!(offset_of!(VkPhysicalDeviceProperties, api_version), 0, "VkPhysicalDeviceProperties.apiVersion");+ assert_eq!(offset_of!(VkPhysicalDeviceProperties, driver_version), 4, "VkPhysicalDeviceProperties.driverVersion");+ assert_eq!(offset_of!(VkPhysicalDeviceProperties, vendor_id), 8, "VkPhysicalDeviceProperties.vendorID");+ assert_eq!(offset_of!(VkPhysicalDeviceProperties, device_id), 12, "VkPhysicalDeviceProperties.deviceID");+ assert_eq!(offset_of!(VkPhysicalDeviceProperties, device_type), 16, "VkPhysicalDeviceProperties.deviceType");+ assert_eq!(offset_of!(VkPhysicalDeviceProperties, device_name), 20, "VkPhysicalDeviceProperties.deviceName");+ assert_eq!((size_of::<VkPhysicalDeviceProperties2>(), align_of::<VkPhysicalDeviceProperties2>()), (840, 8), "VkPhysicalDeviceProperties2");+ assert_eq!(offset_of!(VkPhysicalDeviceProperties2, s_type), 0, "VkPhysicalDeviceProperties2.sType");+ assert_eq!(offset_of!(VkPhysicalDeviceProperties2, p_next), 8, "VkPhysicalDeviceProperties2.pNext");+ assert_eq!(offset_of!(VkPhysicalDeviceProperties2, properties), 16, "VkPhysicalDeviceProperties2.properties");+ assert_eq!((size_of::<VkPhysicalDeviceDrmPropertiesEXT>(), align_of::<VkPhysicalDeviceDrmPropertiesEXT>()), (56, 8), "VkPhysicalDeviceDrmPropertiesEXT");+ assert_eq!(offset_of!(VkPhysicalDeviceDrmPropertiesEXT, s_type), 0, "drm.sType");+ assert_eq!(offset_of!(VkPhysicalDeviceDrmPropertiesEXT, p_next), 8, "drm.pNext");+ assert_eq!(offset_of!(VkPhysicalDeviceDrmPropertiesEXT, has_primary), 16, "drm.hasPrimary");+ assert_eq!(offset_of!(VkPhysicalDeviceDrmPropertiesEXT, has_render), 20, "drm.hasRender");+ assert_eq!(offset_of!(VkPhysicalDeviceDrmPropertiesEXT, primary_major), 24, "drm.primaryMajor");+ assert_eq!(offset_of!(VkPhysicalDeviceDrmPropertiesEXT, primary_minor), 32, "drm.primaryMinor");+ assert_eq!(offset_of!(VkPhysicalDeviceDrmPropertiesEXT, render_major), 40, "drm.renderMajor");+ assert_eq!(offset_of!(VkPhysicalDeviceDrmPropertiesEXT, render_minor), 48, "drm.renderMinor");+ assert_eq!(size_of::<VkExtensionProperties>(), 260, "VkExtensionProperties");+ assert_eq!(offset_of!(VkExtensionProperties, extension_name), 0, "VkExtensionProperties.extensionName");+ assert_eq!(offset_of!(VkExtensionProperties, spec_version), 256, "VkExtensionProperties.specVersion");+ assert_eq!(VK_MAX_EXTENSION_NAME_SIZE, 256, "VK_MAX_EXTENSION_NAME_SIZE");+}++#[test]+fn constants_match_the_header() {+ assert_eq!(VK_SUCCESS, 0, "VK_SUCCESS");+ assert_eq!(VK_INCOMPLETE, 5, "VK_INCOMPLETE");+ assert_eq!(VK_STRUCTURE_TYPE_APPLICATION_INFO, 0, "VK_STRUCTURE_TYPE_APPLICATION_INFO");+ assert_eq!(VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO, 1, "VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO");+ assert_eq!(VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2, 1000059001, "VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2");+ assert_eq!(VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRM_PROPERTIES_EXT, 1000353000, "VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRM_PROPERTIES_EXT");+ assert_eq!(VK_API_VERSION_1_1, 0x00401000, "VK_MAKE_API_VERSION(0, 1, 1, 0)");+ assert_eq!(version_major(VK_API_VERSION_1_1), 1, "VK_VERSION_MAJOR");+ assert_eq!(version_minor(VK_API_VERSION_1_1), 1, "VK_VERSION_MINOR");+}new file mode 100644@@ -0,0 +1,148 @@+//! The Vulkan entry points and structures this crate uses (vulkan.h, Vulkan 1.1 and the DRM+//! extensions), transcribed and pinned against the header in `abi_tests.rs`. Only what the DMABUF+//! import probe needs: no device is created here, no image is made.+//!+//! The declarations follow the header's names, as vaapi's do.+#![allow(non_camel_case_types)]++use std::ffi::{c_char, c_int, c_uint, c_void};++use libloading::Library;++/// `VkResult`: 0 is `VK_SUCCESS`.+pub const VK_SUCCESS: c_int = 0;+/// `VkResult`: an enumeration filled the array it was given and has more entries.+pub const VK_INCOMPLETE: c_int = 5;+pub const VK_STRUCTURE_TYPE_APPLICATION_INFO: c_int = 0;+pub const VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO: c_int = 1;+pub const VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2: c_int = 1000059001;+pub const VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRM_PROPERTIES_EXT: c_int = 1000353000;+/// `VK_MAKE_API_VERSION(0, 1, 1, 0)`: Vulkan 1.1, where `vkGetPhysicalDeviceProperties2` is core.+pub const VK_API_VERSION_1_1: u32 = (1 << 22) | (1 << 12);+pub const VK_MAX_EXTENSION_NAME_SIZE: usize = 256;++/// `VK_VERSION_MAJOR` / `VK_VERSION_MINOR` of an `apiVersion` word.+pub const fn version_major(v: u32) -> u32 {+ v >> 22+}+pub const fn version_minor(v: u32) -> u32 {+ (v >> 12) & 0x3ff+}++/// The extensions the DMABUF import needs (and the one that says which GPU a device is).+pub const EXT_EXTERNAL_MEMORY_DMA_BUF: &str = "VK_EXT_external_memory_dma_buf";+pub const EXT_IMAGE_DRM_FORMAT_MODIFIER: &str = "VK_EXT_image_drm_format_modifier";+pub const EXT_PHYSICAL_DEVICE_DRM: &str = "VK_EXT_physical_device_drm";++/// `VkAllocationCallbacks`: the loader's default allocator (a null pointer) is what we pass.+pub type VkAllocationCallbacks = c_void;+pub type VkInstance = *mut c_void;+pub type VkPhysicalDevice = *mut c_void;++#[repr(C)]+#[derive(Clone, Copy)]+pub struct VkApplicationInfo {+ pub s_type: c_int,+ pub p_next: *const c_void,+ pub p_application_name: *const c_char,+ pub application_version: u32,+ pub p_engine_name: *const c_char,+ pub engine_version: u32,+ pub api_version: u32,+}++#[repr(C)]+#[derive(Clone, Copy)]+pub struct VkInstanceCreateInfo {+ pub s_type: c_int,+ pub p_next: *const c_void,+ pub flags: u32,+ pub p_application_info: *const VkApplicationInfo,+ pub enabled_layer_count: u32,+ pub pp_enabled_layer_names: *const *const c_char,+ pub enabled_extension_count: u32,+ pub pp_enabled_extension_names: *const *const c_char,+}++#[repr(C)]+#[derive(Clone, Copy)]+pub struct VkExtensionProperties {+ pub extension_name: [c_char; VK_MAX_EXTENSION_NAME_SIZE],+ pub spec_version: u32,+}++// [T; 256] has no `Default` (std stops at 32), and a zeroed structure is what the API wants.+impl Default for VkExtensionProperties {+ fn default() -> Self {+ VkExtensionProperties { extension_name: [0; VK_MAX_EXTENSION_NAME_SIZE], spec_version: 0 }+ }+}++/// `VkPhysicalDeviceProperties`: 824 bytes, of which only the first fields are read. The tail is+/// carried so the driver writes into a buffer of the size it expects (abi_tests.rs pins both).+#[repr(C, align(8))]+#[derive(Clone, Copy)]+pub struct VkPhysicalDeviceProperties {+ pub api_version: u32,+ pub driver_version: u32,+ pub vendor_id: u32,+ pub device_id: u32,+ /// `VK_PHYSICAL_DEVICE_TYPE_*`.+ pub device_type: u32,+ pub device_name: [c_char; VK_MAX_EXTENSION_NAME_SIZE],+ /// `limits` and `sparseProperties`: never read here.+ pub tail: [u8; 824 - 20 - VK_MAX_EXTENSION_NAME_SIZE],+}++impl Default for VkPhysicalDeviceProperties {+ fn default() -> Self {+ VkPhysicalDeviceProperties {+ api_version: 0,+ driver_version: 0,+ vendor_id: 0,+ device_id: 0,+ device_type: 0,+ device_name: [0; VK_MAX_EXTENSION_NAME_SIZE],+ tail: [0; 824 - 20 - VK_MAX_EXTENSION_NAME_SIZE],+ }+ }+}++#[repr(C)]+#[derive(Clone, Copy, Default)]+pub struct VkPhysicalDeviceProperties2 {+ pub s_type: c_int,+ pub p_next: *mut c_void,+ pub properties: VkPhysicalDeviceProperties,+}++#[repr(C)]+#[derive(Clone, Copy)]+pub struct VkPhysicalDeviceDrmPropertiesEXT {+ pub s_type: c_int,+ pub p_next: *mut c_void,+ pub has_primary: u32,+ pub has_render: u32,+ pub primary_major: u64,+ pub primary_minor: u64,+ pub render_major: u64,+ pub render_minor: u64,+}++pub type vkCreateInstance = unsafe extern "C" fn(*const VkInstanceCreateInfo, *const VkAllocationCallbacks, *mut VkInstance) -> c_int;+pub type vkDestroyInstance = unsafe extern "C" fn(VkInstance, *const VkAllocationCallbacks);+pub type vkEnumeratePhysicalDevices = unsafe extern "C" fn(VkInstance, *mut c_uint, *mut VkPhysicalDevice) -> c_int;+pub type vkGetPhysicalDeviceProperties = unsafe extern "C" fn(VkPhysicalDevice, *mut VkPhysicalDeviceProperties);+pub type vkGetPhysicalDeviceProperties2 = unsafe extern "C" fn(VkPhysicalDevice, *mut VkPhysicalDeviceProperties2);+pub type vkEnumerateDeviceExtensionProperties = unsafe extern "C" fn(VkPhysicalDevice, *const c_char, *mut c_uint, *mut VkExtensionProperties) -> c_int;++/// The loader and the entry points this crate uses (it stays loaded for the life of the process).+pub struct Api {+ pub create_instance: vkCreateInstance,+ pub destroy_instance: vkDestroyInstance,+ pub enumerate_physical_devices: vkEnumeratePhysicalDevices,+ pub get_physical_device_properties: vkGetPhysicalDeviceProperties,+ pub get_physical_device_properties2: vkGetPhysicalDeviceProperties2,+ pub enumerate_device_extension_properties: vkEnumerateDeviceExtensionProperties,+ pub _lib: Library,+}new file mode 100644@@ -0,0 +1,320 @@+//! Vulkan, for one question only: can a decoded VA-API surface's DMABUF be imported where the+//! compositor draws?+//!+//! The zero-copy path is: `vaapi::va::Display::export_surface` hands over the objects and plane+//! layout, and a Vulkan image made from them (`VK_EXT_external_memory_dma_buf` ++//! `VK_EXT_image_drm_format_modifier`) is what the compositor would sample instead of a readback.+//! Importing needs the *same* device the compositor uses, so this module also asks each device for+//! its DRM nodes (`VK_EXT_physical_device_drm`) and compares them with the VA display's render+//! node: a mismatch means zero-copy is not available on this machine (two GPUs, or a decoder+//! without a Vulkan device).+//!+//! Only the probe is here. Binding the image where wgpu draws needs raw Vulkan handles: wgpu's safe+//! API has no external-memory import, so the compositor would have to take the `hal` path+//! (`wgpu-hal`, `VkImage` + `VkDeviceMemory` from `vkAllocateMemory` with the imported fd, then a+//! `wgpu::Texture` around it). Until that lands, the readback stays the only path.+//!+//! FFI module (docs/adr/0001-platform-ffi.md): `libvulkan.so.1` is loaded at run time, every+//! `unsafe` block has a `// SAFETY:` comment, the public API is safe, and what the driver reports+//! is checked before it is used. The C layout of every structure is pinned in `abi_tests.rs`.++use std::ffi::{c_char, c_uint};+use std::sync::OnceLock;++use libloading::Library;++#[cfg(test)]+mod abi_tests;+mod ffi;++/// What one Vulkan device offers the DMABUF import, and whether it is the decoder's own device.+#[derive(Clone, Debug, PartialEq, Eq)]+pub struct ImportCapability {+ /// The Vulkan device's name (`VkPhysicalDeviceProperties::deviceName`).+ pub device: String,+ /// Its `apiVersion`, as (major, minor).+ pub api_version: (u32, u32),+ /// `VK_EXT_external_memory_dma_buf`: the device takes memory from a DMABUF file descriptor.+ pub dma_buf: bool,+ /// `VK_EXT_image_drm_format_modifier`: it takes the plane layout and the DRM format modifier+ /// the VA driver decoded with (what `SurfaceDmabuf` describes).+ pub format_modifiers: bool,+ /// The device's DRM render node, when it reports one (`VK_EXT_physical_device_drm`).+ pub drm_render_node: Option<(u32, u32)>,+ /// Whether that node is the VA display's own. None only when no VA node was given to+ /// [`import_capability`]; `Some(false)` when the device is on another node or reports none+ /// (it cannot be shown to be the same GPU, so [`ready`](Self::ready) declines rather than+ /// guessing).+ pub same_gpu: Option<bool>,+}++impl ImportCapability {+ /// Whether the compositor could take a VA-API DMABUF here on this device.+ pub fn ready(&self) -> bool {+ self.dma_buf && self.format_modifiers && self.same_gpu != Some(false)+ }+}++/// The device the probe chose and what it offers, or why there is none. `drm` is the VA display's+/// render node (`(major, minor)`, [`crate::vaapi::va::Display::render_node`]): pass None when there+/// is no display to match, and `same_gpu` stays None.+pub fn import_capability(drm: Option<(u32, u32)>) -> Result<ImportCapability, String> {+ let devices = devices().map_err(|e| format!("Vulkan: {e}"))?;+ // Prefer the device on the decoder's own node; otherwise the first one.+ let found = match drm {+ Some(node) => devices.iter().find(|d| d.drm_render_node == Some(node)).or(devices.first()),+ None => devices.first(),+ };+ let mut cap = found.ok_or("Vulkan: no physical device")?.clone();+ cap.same_gpu = drm.map(|node| cap.drm_render_node == Some(node));+ Ok(cap)+}++/// Whether the loaded Vulkan reports the two extensions the DMABUF import needs, on any device.+pub fn import_possible() -> bool {+ devices().is_ok_and(|d| d.iter().any(|c| c.dma_buf && c.format_modifiers))+}++fn devices() -> Result<&'static [ImportCapability], String> {+ static DEVICES: OnceLock<Result<Vec<ImportCapability>, String>> = OnceLock::new();+ DEVICES.get_or_init(enumerate).as_deref().map_err(Clone::clone)+}++/// Enumerate the physical devices once, with what each offers (probe and diagnostics).+fn enumerate() -> Result<Vec<ImportCapability>, String> {+ let api = api()?;+ let app = ffi::VkApplicationInfo {+ s_type: ffi::VK_STRUCTURE_TYPE_APPLICATION_INFO,+ p_next: std::ptr::null(),+ p_application_name: c"FilmCraft".as_ptr(),+ application_version: 0,+ p_engine_name: c"FilmCraft".as_ptr(),+ engine_version: 0,+ api_version: ffi::VK_API_VERSION_1_1,+ };+ let create = ffi::VkInstanceCreateInfo {+ s_type: ffi::VK_STRUCTURE_TYPE_INSTANCE_CREATE_INFO,+ p_next: std::ptr::null(),+ flags: 0,+ p_application_info: &app,+ enabled_layer_count: 0,+ pp_enabled_layer_names: std::ptr::null(),+ enabled_extension_count: 0,+ pp_enabled_extension_names: std::ptr::null(),+ };+ let mut instance = std::ptr::null_mut();+ // SAFETY: `create` points at a live descriptor with an `app` info chain; no layers or instance+ // extensions are asked for, so nothing else is dereferenced. `instance` is a live output.+ let st = unsafe { (api.create_instance)(&create, std::ptr::null(), &mut instance) };+ if st != ffi::VK_SUCCESS {+ return Err(format!("vkCreateInstance: {st}"));+ }+ if instance.is_null() {+ return Err("vkCreateInstance returned a null instance".into());+ }+ // From here on the instance is destroyed on every path (an uninitialised one cannot be).+ let out = physical_devices(api, instance);+ // SAFETY: `instance` came from vkCreateInstance above and is destroyed exactly once, here.+ unsafe { (api.destroy_instance)(instance, std::ptr::null()) };+ out+}++/// Most physical devices read (a machine has a handful; the count is the driver's).+const MAX_DEVICES: c_uint = 64;+/// Most device extensions read (drivers list a few hundred).+const MAX_EXTENSIONS: c_uint = 4096;++fn physical_devices(api: &'static ffi::Api, instance: ffi::VkInstance) -> Result<Vec<ImportCapability>, String> {+ let mut count: c_uint = 0;+ // SAFETY: `instance` is a live instance; a null array with a live count is the documented way+ // to ask how many devices there are.+ let st = unsafe { (api.enumerate_physical_devices)(instance, &mut count, std::ptr::null_mut()) };+ if st != ffi::VK_SUCCESS {+ return Err(format!("vkEnumeratePhysicalDevices: {st}"));+ }+ if count == 0 {+ return Ok(Vec::new());+ }+ // (the count is the driver's: the array is capped, and a capped one reads as VK_INCOMPLETE)+ let mut got = count.min(MAX_DEVICES);+ let n = got as usize;+ let mut raw = vec![std::ptr::null_mut(); n];+ // SAFETY: `raw` has room for `got` handles; the driver writes at most that many and reports+ // how many it wrote in `got`.+ let st = unsafe { (api.enumerate_physical_devices)(instance, &mut got, raw.as_mut_ptr()) };+ if st != ffi::VK_SUCCESS && st != ffi::VK_INCOMPLETE {+ return Err(format!("vkEnumeratePhysicalDevices: {st}"));+ }+ let got = usize::try_from(got).map_err(|_| "vkEnumeratePhysicalDevices: absurd device count")?.min(n);+ let mut out = Vec::with_capacity(got);+ for dev in raw.into_iter().take(got) {+ if dev.is_null() {+ continue;+ }+ out.push(one_device(api, dev));+ }+ Ok(out)+}++fn one_device(api: &'static ffi::Api, dev: ffi::VkPhysicalDevice) -> ImportCapability {+ let mut props: ffi::VkPhysicalDeviceProperties = Default::default();+ // SAFETY: `dev` is one of the handles just enumerated; `props` is a live, correctly sized+ // `VkPhysicalDeviceProperties` (abi_tests.rs pins its layout).+ unsafe { (api.get_physical_device_properties)(dev, &mut props) };+ let extensions = device_extensions(api, dev);+ let dma_buf = extensions.iter().any(|e| e == ffi::EXT_EXTERNAL_MEMORY_DMA_BUF);+ let format_modifiers = extensions.iter().any(|e| e == ffi::EXT_IMAGE_DRM_FORMAT_MODIFIER);+ let drm_render_node = if extensions.iter().any(|e| e == ffi::EXT_PHYSICAL_DEVICE_DRM) { render_node(api, dev) } else { None };+ ImportCapability {+ device: c_string(&props.device_name),+ api_version: (ffi::version_major(props.api_version), ffi::version_minor(props.api_version)),+ dma_buf,+ format_modifiers,+ drm_render_node,+ same_gpu: None,+ }+}++/// The device's DRM render node (`VK_EXT_physical_device_drm`), when it reports one.+fn render_node(api: &'static ffi::Api, dev: ffi::VkPhysicalDevice) -> Option<(u32, u32)> {+ let mut drm = ffi::VkPhysicalDeviceDrmPropertiesEXT {+ s_type: ffi::VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_DRM_PROPERTIES_EXT,+ p_next: std::ptr::null_mut(),+ has_primary: 0,+ has_render: 0,+ primary_major: 0,+ primary_minor: 0,+ render_major: 0,+ render_minor: 0,+ };+ let mut props = ffi::VkPhysicalDeviceProperties2 {+ s_type: ffi::VK_STRUCTURE_TYPE_PHYSICAL_DEVICE_PROPERTIES_2,+ p_next: (&raw mut drm).cast(),+ properties: Default::default(),+ };+ // SAFETY: `dev` is a live physical device; `props` is a live `VkPhysicalDeviceProperties2`+ // whose `pNext` chain is one live `VkPhysicalDeviceDrmPropertiesEXT` with the structure type+ // the extension defines (abi_tests.rs pins both layouts).+ unsafe { (api.get_physical_device_properties2)(dev, &mut props) };+ if drm.has_render == 0 {+ return None;+ }+ let (major, minor) = (drm.render_major, drm.render_minor);+ // the driver reports the kernel's dev_t; a node that does not fit our range is not one+ Some((u32::try_from(major).ok()?, u32::try_from(minor).ok()?))+}++fn device_extensions(api: &'static ffi::Api, dev: ffi::VkPhysicalDevice) -> Vec<String> {+ let mut count: c_uint = 0;+ // SAFETY: `dev` is a live device; a null array with a live count asks how many there are.+ let st = unsafe { (api.enumerate_device_extension_properties)(dev, std::ptr::null(), &mut count, std::ptr::null_mut()) };+ if st != ffi::VK_SUCCESS || count == 0 {+ return Vec::new();+ }+ // (the count is the driver's: the array is capped, and a capped one reads as VK_INCOMPLETE)+ count = count.min(MAX_EXTENSIONS);+ let mut props = vec![ffi::VkExtensionProperties::default(); count as usize];+ // SAFETY: `props` has room for `count` entries; the driver writes at most that many and+ // reports how many it wrote in `count`.+ let st = unsafe { (api.enumerate_device_extension_properties)(dev, std::ptr::null(), &mut count, props.as_mut_ptr()) };+ if st != ffi::VK_SUCCESS && st != ffi::VK_INCOMPLETE {+ return Vec::new();+ }+ props.iter().take(count as usize).map(|p| c_string(&p.extension_name)).collect()+}++/// A NUL-terminated fixed-size C string, lossily decoded.+fn c_string(buf: &[c_char]) -> String {+ let bytes: Vec<u8> = buf.iter().map(|c| *c as u8).collect();+ let end = bytes.iter().position(|b| *b == 0).unwrap_or(bytes.len());+ String::from_utf8_lossy(&bytes[..end]).into_owned()+}++static API: OnceLock<Result<ffi::Api, String>> = OnceLock::new();++fn api() -> Result<&'static ffi::Api, String> {+ API.get_or_init(load).as_ref().map_err(Clone::clone)+}++/// Load `libvulkan.so.1` and its entry points (the only ones this probe needs).+fn load() -> Result<ffi::Api, String> {+ // SAFETY: loading Vulkan runs its library initialisers, which set up Vulkan's own state; it is+ // the system's Vulkan loader, loaded the way every client loads it.+ let lib = unsafe { Library::new("libvulkan.so.1") }.map_err(|e| format!("libvulkan.so.1: {e}"))?;+ macro_rules! sym {+ ($name:ident) => {{+ // SAFETY: `ffi::$name` is the C signature of the Vulkan function of that name,+ // transcribed from vulkan.h and checked against it (abi_tests.rs). The pointer is+ // copied out; the library stays loaded in `ffi::Api` for the life of the process.+ let s = unsafe { lib.get::<ffi::$name>(concat!(stringify!($name), "\0").as_bytes()) };+ *s.map_err(|e| format!("{}: {e}", stringify!($name)))?+ }};+ }+ let api = ffi::Api {+ create_instance: sym!(vkCreateInstance),+ destroy_instance: sym!(vkDestroyInstance),+ enumerate_physical_devices: sym!(vkEnumeratePhysicalDevices),+ get_physical_device_properties: sym!(vkGetPhysicalDeviceProperties),+ enumerate_device_extension_properties: sym!(vkEnumerateDeviceExtensionProperties),+ // Vulkan 1.1, where this is core: without it a device cannot be shown to be the decoder's+ // own, so the probe declines rather than guessing (the readback stays the path).+ get_physical_device_properties2: sym!(vkGetPhysicalDeviceProperties2),+ _lib: lib,+ };+ Ok(api)+}++#[cfg(test)]+mod tests {+ use super::*;++ /// The probe finds a Vulkan device and says what it offers. Skips when there is no loader or+ /// no device (a container, a headless builder).+ #[test]+ fn probe_reports_the_devices_capability() {+ let Ok(caps) = devices() else {+ eprintln!("no Vulkan loader or device, skipping");+ return;+ };+ assert!(!caps.is_empty(), "a loader with no device");+ for c in caps {+ assert!(!c.device.is_empty(), "{c:?}");+ assert!(c.api_version.0 >= 1, "{c:?}");+ eprintln!(+ "vulkan: {} api {}.{} dma_buf={} modifiers={} node={:?}",+ c.device, c.api_version.0, c.api_version.1, c.dma_buf, c.format_modifiers, c.drm_render_node+ );+ }+ // the combined answer, which is what the compositor would ask+ let cap = import_capability(None).expect("a device was just enumerated");+ eprintln!("import capability: {cap:?} (ready {})", cap.ready());+ assert_eq!(cap.same_gpu, None, "no node to match without a display");+ }++ /// With a VA-API display, the device on its render node is the one the import would use. Skips+ /// without a driver.+ #[test]+ fn the_decoders_render_node_matches_a_vulkan_device() {+ let Ok(display) = crate::vaapi::va::Display::open() else {+ eprintln!("no VA-API driver, skipping");+ return;+ };+ let Some(node) = display.render_node() else {+ eprintln!("the display does not report its render node, skipping");+ return;+ };+ let Ok(caps) = devices() else {+ eprintln!("no Vulkan loader or device, skipping");+ return;+ };+ for c in caps {+ if c.drm_render_node == Some(node) {+ eprintln!("{} is the decoder's device (node {node:?})", c.device);+ }+ }+ let cap = import_capability(Some(node)).expect("a device was just enumerated");+ // the capability is reported for the matched device, whatever it is+ eprintln!("decoder node {node:?} → {cap:?}");+ assert!(cap.same_gpu.is_some(), "a node was asked for");+ }+}@@ -26,7 +26,7 @@ Containment rules: 1. The crate does not use `lints.workspace = true`. Its own `[lints]` table copies the workspace lints except `unsafe_code = "deny"` (not `forbid`), and adds `clippy::undocumented_unsafe_blocks = "deny"`. Only the FFI modules (`videotoolbox`, `media_foundation::gpu` / `media_foundation::mft`,- `nvenc::ffi` / `nvenc::session` / `nvenc::device` on Windows and 64-bit Linux, and `vaapi::va` on Linux) carry `#[allow(unsafe_code)]`; the rest of the crate+ `nvenc::ffi` / `nvenc::session` / `nvenc::device` on Windows and 64-bit Linux, `vaapi::va` and `vulkan` on Linux) carry `#[allow(unsafe_code)]`; the rest of the crate (the fallback logic in `hybrid`, the decoder logic in `media_foundation`, `annexb`, `biplanar`, the encoder logic in `nvenc`, H.264 and H.265 alike) has no `unsafe`. NVENC's H.265 support added no `unsafe` module: it reuses `nvenc::ffi` and `nvenc::session` (new data declarations and@@ -157,3 +157,39 @@ 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.++## Addendum (2026-10-24): Vulkan DMABUF import probe (Linux)++Importing a VA-API DMABUF into the compositor needs Vulkan's external-memory import, which wgpu's+safe API does not have. Before that path is taken, the compositor must know whether it can work:+whether the Vulkan device it draws on is the decoder's GPU and takes the DMABUF memory and plane+layout the VA driver produced. `vulkan` is that probe, and the one new FFI module:++- `libvulkan.so.1` is loaded at run time through `libloading`, as libva is; building needs no Vulkan+ headers, and on a system without a loader `import_capability` returns an error and+ `import_possible()` is false; playback is as before. The declarations+ (`vulkan/ffi.rs`) are transcribed from Khronos's `vulkan.h` (Apache-2.0 OR MIT), and+ `vulkan/abi_tests.rs` pins every structure and constant against it.+- It creates one instance, only to enumerate physical devices, their properties, device extensions+ and (`VK_EXT_physical_device_drm`) DRM render nodes, and destroys it. No device, memory or image+ is created. Driver-reported counts are capped (64 devices, 4096 extensions) before anything is+ allocated, and `VK_INCOMPLETE` is accepted.+- There are no callbacks into Rust, every `unsafe` block has a `// SAFETY:` comment, and the public+ API (`import_capability`, `import_possible`, `ImportCapability::ready`) is safe plain data.++It is a coarse prerequisite check: the extensions and the render node. It does not establish that+a given surface's fourcc, DRM format modifier and plane layout can be imported, or that the device+is the one wgpu chose; an importer still has to validate each surface and fall back to the+readback. It is still OS media FFI for hardware decoding: the probe exists only to decide whether+a decoded picture can stay on the GPU. The rules above hold, and the readback remains the fallback.@@ -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