Skip to main content

alpha2_native/
hwdecode.rs

1//! Standalone Linux Vulkan hardware-decode probe, run by the CLI to check a
2//! device's decode path without an export.
3
4use std::collections::HashSet;
5use std::ffi::CString;
6use std::path::Path;
7
8use alpha2_ffmpeg_bridge::{VulkanFrameInfo, VulkanFrameLease, pixel_format_name};
9use alpha2_media::DecodeError;
10use alpha2_native_gpu::probe_vulkan_submission;
11use rsmpeg::avcodec::{AVCodec, AVCodecContext};
12use rsmpeg::avutil::{AVFrame, AVHWDeviceContext};
13use rsmpeg::error::RsmpegError;
14use rsmpeg::ffi;
15
16use crate::convert::adapter_error;
17use crate::decode::{open_input, probe_context};
18
19/// Aggregate result from a bounded Vulkan hardware decode and surface probe.
20#[derive(Clone, Debug, Eq, PartialEq)]
21pub struct VulkanDecodeProbe {
22    /// `FFmpeg` decoder implementation.
23    pub decoder: String,
24    /// Selected opaque hardware pixel format.
25    pub hardware_format: String,
26    /// Actual decoded surface layout.
27    pub software_format: String,
28    /// Display size reported by the decoded frames.
29    pub display_size: (i32, i32),
30    /// Allocated surface extent, padded to whole macroblocks by the codec.
31    pub coded_size: (i32, i32),
32    /// Number of hardware frames received before the bound.
33    pub hardware_frames: u64,
34    /// Number of distinct `VkImage` identities observed.
35    pub unique_images: usize,
36    /// Frames whose primary image had already appeared, proving pool reuse.
37    pub reused_images: u64,
38    /// Whether every decoded image included sampled-image usage.
39    pub all_images_sampled: bool,
40    /// Whether every image exposed a ready timeline semaphore.
41    pub all_images_synchronized: bool,
42    /// Number of hardware frames received after seeking back to the start.
43    pub seek_frames: u64,
44    /// Device identity shared by all observed surfaces.
45    pub device: u64,
46    /// Physical-device identity shared by all observed surfaces.
47    pub physical_device: u64,
48    /// Maximum number of Vulkan images backing one decoded frame.
49    pub max_plane_images: u32,
50    /// GPU-only synchronization/layout submissions completed by the probe.
51    pub interop_submissions: u64,
52    /// CPU source-plane copies performed by this probe.
53    pub source_cpu_plane_copy_bytes: u64,
54    /// CPU-to-GPU uploads performed by this probe.
55    pub source_upload_bytes: u64,
56    /// GPU-to-CPU downloads performed by this probe.
57    pub source_download_bytes: u64,
58}
59
60/// Decodes Vulkan frames without mapping, downloading, or uploading their planes.
61///
62/// The probe verifies the codec hardware configuration, receives actual
63/// `AV_PIX_FMT_VULKAN` frames, reads their image/layout/timeline state through
64/// the platform shim, observes pool reuse, and repeats decode after a seek.
65/// It opens its own device and decoder rather than going through
66/// [`crate::NativeExportDecoder`], so it observes `FFmpeg`'s surfaces directly.
67///
68/// # Errors
69/// Returns an error if Vulkan decode is unavailable or any received frame does
70/// not satisfy the resident-surface contract.
71#[allow(clippy::too_many_lines)]
72pub fn probe_vulkan_decode(
73    path: impl AsRef<Path>,
74    frame_limit: usize,
75) -> Result<VulkanDecodeProbe, DecodeError> {
76    if frame_limit < 2 {
77        return Err(DecodeError::Unsupported(
78            "Vulkan decode probe requires at least two frames".into(),
79        ));
80    }
81    let mut input = open_input(path.as_ref())?;
82    let selected = probe_context(&input, path.as_ref())?
83        .selected_video
84        .ok_or_else(|| DecodeError::Unsupported("source has no video stream".into()))?;
85    input.discard_all_but(selected);
86    let parameters = input
87        .streams()
88        .get(selected)
89        .ok_or(DecodeError::OutOfRange)?
90        .codecpar();
91    let preferred = AVCodec::find_decoder(parameters.codec_id)
92        .ok_or_else(|| DecodeError::Unsupported("selected video codec has no decoder".into()))?;
93    // The preferred decoder may be a software one (libdav1d for AV1); the
94    // Vulkan configuration lives on `FFmpeg`'s own decoder for the codec.
95    let (codec, config) = crate::decode::registered_video_decoders(parameters.codec_id)
96        .find_map(|codec| {
97            let config = (0..)
98                .map_while(|index| codec.hw_config(index))
99                .find(|config| {
100                    config.device_type == ffi::AV_HWDEVICE_TYPE_VULKAN
101                        && config.pix_fmt == ffi::AV_PIX_FMT_VULKAN
102                        && crate::decode::has_hw_config_method(
103                            config.methods,
104                            ffi::AV_CODEC_HW_CONFIG_METHOD_HW_DEVICE_CTX,
105                        )
106                })?;
107            Some((codec, config))
108        })
109        .ok_or_else(|| {
110            DecodeError::Unsupported(format!(
111                "decoder {} has no Vulkan HW_DEVICE_CTX configuration",
112                preferred.name().to_string_lossy()
113            ))
114        })?;
115    debug_assert_eq!(config.pix_fmt, ffi::AV_PIX_FMT_VULKAN);
116
117    // The device an export decodes on, as the host chooses it, passed by its index: `FFmpeg`
118    // reads a name that starts with a digit as an index, so passing `ALPHA2_VULKAN_DEVICE` on
119    // unchanged would pick another device or none.
120    let requested_device = CString::new(
121        alpha2_native_gpu::shared_device_index(
122            std::env::var("ALPHA2_VULKAN_DEVICE").ok().as_deref(),
123        )
124        .map_err(|error| DecodeError::Unsupported(error.to_string()))?
125        .to_string(),
126    )
127    .map_err(|_| DecodeError::Unsupported("a device index held a NUL byte".into()))?;
128    let device = AVHWDeviceContext::create(
129        ffi::AV_HWDEVICE_TYPE_VULKAN,
130        Some(&requested_device),
131        None,
132        0,
133    )
134    .map_err(adapter_error)?;
135    let mut decoder = AVCodecContext::new(&codec);
136    decoder.apply_codecpar(&parameters).map_err(adapter_error)?;
137    drop(parameters);
138    decoder.set_pkt_timebase(input.streams()[selected].time_base);
139    decoder.set_get_format(Some(alpha2_ffmpeg_bridge::vulkan_get_format_callback()));
140    decoder.set_hw_device_ctx(device);
141    decoder.open(None).map_err(adapter_error)?;
142
143    let mut demux_eof = false;
144    let mut flushed = false;
145    let mut images = HashSet::new();
146    let mut reuse = 0_u64;
147    let mut first_info = None;
148    let mut all_sampled = true;
149    let mut all_synchronized = true;
150    let mut max_plane_images = 0;
151    let mut frames = 0_u64;
152    while frames < frame_limit as u64 {
153        let Some(frame) = decode_next_raw(
154            &mut input,
155            &mut decoder,
156            selected,
157            &mut demux_eof,
158            &mut flushed,
159        )?
160        else {
161            break;
162        };
163        let lease = VulkanFrameLease::new(frame);
164        let info = validate_frame(&lease, first_info.as_ref())?;
165        probe_vulkan_submission(&lease).map_err(adapter_error)?;
166        let primary = info
167            .images
168            .iter()
169            .copied()
170            .find(|image| *image != 0)
171            .ok_or_else(|| DecodeError::Adapter("Vulkan frame has no image".into()))?;
172        if !images.insert(primary) {
173            reuse += 1;
174        }
175        all_sampled &= info.sampled;
176        all_synchronized &= synchronized(&info);
177        max_plane_images = max_plane_images.max(info.plane_count);
178        first_info.get_or_insert(info);
179        frames += 1;
180    }
181    let first_info = first_info.ok_or_else(|| {
182        DecodeError::Unsupported("decoder returned no Vulkan hardware frame".into())
183    })?;
184    if !all_sampled {
185        return Err(DecodeError::Unsupported(
186            "decoded Vulkan image is not sampleable".into(),
187        ));
188    }
189    if !all_synchronized {
190        return Err(DecodeError::Adapter(
191            "decoded Vulkan image has no ready timeline semaphore".into(),
192        ));
193    }
194
195    input
196        .seek(
197            i32::try_from(selected).map_err(|_| DecodeError::OutOfRange)?,
198            0,
199            ffi::AVSEEK_FLAG_BACKWARD.cast_signed(),
200        )
201        .map_err(adapter_error)?;
202    decoder.flush_buffers();
203    demux_eof = false;
204    flushed = false;
205    let seek_frame = decode_next_raw(
206        &mut input,
207        &mut decoder,
208        selected,
209        &mut demux_eof,
210        &mut flushed,
211    )?
212    .ok_or_else(|| DecodeError::Adapter("no Vulkan frame after seek".into()))?;
213    let seek_lease = VulkanFrameLease::new(seek_frame);
214    validate_frame(&seek_lease, Some(&first_info))?;
215    probe_vulkan_submission(&seek_lease).map_err(adapter_error)?;
216
217    Ok(VulkanDecodeProbe {
218        decoder: codec.name().to_string_lossy().into_owned(),
219        hardware_format: format_name(first_info.hardware_format),
220        software_format: format_name(first_info.software_format),
221        display_size: (first_info.width, first_info.height),
222        coded_size: (first_info.coded_width, first_info.coded_height),
223        hardware_frames: frames,
224        unique_images: images.len(),
225        reused_images: reuse,
226        all_images_sampled: all_sampled,
227        all_images_synchronized: all_synchronized,
228        seek_frames: 1,
229        device: first_info.device,
230        physical_device: first_info.physical_device,
231        max_plane_images,
232        interop_submissions: frames + 1,
233        source_cpu_plane_copy_bytes: 0,
234        source_upload_bytes: 0,
235        source_download_bytes: 0,
236    })
237}
238
239fn decode_next_raw(
240    input: &mut alpha2_ffmpeg_bridge::DemuxInput,
241    decoder: &mut AVCodecContext,
242    stream_index: usize,
243    demux_eof: &mut bool,
244    flushed: &mut bool,
245) -> Result<Option<AVFrame>, DecodeError> {
246    loop {
247        match decoder.receive_frame() {
248            Ok(frame) => return Ok(Some(frame)),
249            Err(RsmpegError::DecoderDrainError) => {}
250            Err(RsmpegError::DecoderFlushedError) => return Ok(None),
251            Err(error) => return Err(adapter_error(error)),
252        }
253        if *demux_eof {
254            if !*flushed {
255                decoder.send_packet(None).map_err(adapter_error)?;
256                *flushed = true;
257                continue;
258            }
259            return Ok(None);
260        }
261        let Some(packet) = input.read_packet().map_err(adapter_error)? else {
262            *demux_eof = true;
263            continue;
264        };
265        if usize::try_from(packet.stream_index).ok() == Some(stream_index) {
266            decoder.send_packet(Some(&packet)).map_err(adapter_error)?;
267        }
268    }
269}
270
271fn validate_frame(
272    frame: &VulkanFrameLease,
273    expected: Option<&VulkanFrameInfo>,
274) -> Result<VulkanFrameInfo, DecodeError> {
275    let info = frame.info().map_err(adapter_error)?;
276    if info.hardware_format != ffi::AV_PIX_FMT_VULKAN {
277        return Err(DecodeError::Adapter(format!(
278            "decoder returned {} instead of vulkan",
279            format_name(info.hardware_format)
280        )));
281    }
282    if let Some(expected) = expected {
283        if info.device != expected.device || info.physical_device != expected.physical_device {
284            return Err(DecodeError::Adapter(
285                "Vulkan decoder changed device while probing".into(),
286            ));
287        }
288        if info.software_format != expected.software_format {
289            return Err(DecodeError::Adapter(
290                "Vulkan decoder changed surface format while probing".into(),
291            ));
292        }
293        if info.coded_width != expected.coded_width || info.coded_height != expected.coded_height {
294            return Err(DecodeError::Adapter(
295                "Vulkan decoder changed surface extent while probing".into(),
296            ));
297        }
298    }
299    // Presentation normalizes against the allocated extent, so a surface smaller than the picture
300    // it carries would rescale every sample.
301    if info.coded_width < info.width || info.coded_height < info.height {
302        return Err(DecodeError::Adapter(
303            "Vulkan surface is smaller than the frame it carries".into(),
304        ));
305    }
306    Ok(info)
307}
308
309fn synchronized(info: &VulkanFrameInfo) -> bool {
310    info.images
311        .iter()
312        .enumerate()
313        .filter(|(_, image)| **image != 0)
314        .all(|(index, _)| info.semaphores[index] != 0)
315}
316
317fn format_name(format: ffi::AVPixelFormat) -> String {
318    pixel_format_name(format).unwrap_or_else(|| format!("unknown({format})"))
319}
320
321#[cfg(test)]
322mod tests {
323    use super::*;
324
325    #[test]
326    fn synchronization_ignores_unused_plane_slots() {
327        let info = VulkanFrameInfo {
328            hardware_format: ffi::AV_PIX_FMT_VULKAN,
329            software_format: ffi::AV_PIX_FMT_NV12,
330            width: 16,
331            height: 16,
332            coded_width: 16,
333            coded_height: 16,
334            plane_count: 1,
335            sampled: true,
336            instance: 1,
337            physical_device: 2,
338            device: 3,
339            images: [4, 0, 0, 0, 0, 0, 0, 0],
340            formats: [1; 8],
341            layouts: [0; 8],
342            access: [0; 8],
343            semaphores: [5, 0, 0, 0, 0, 0, 0, 0],
344            semaphore_values: [0; 8],
345            queue_families: [0; 8],
346        };
347        assert!(synchronized(&info));
348    }
349}