From e45d7c594b90249dba34f110e6960602c80b963b Mon Sep 17 00:00:00 2001 From: DatCaptainHorse Date: Sat, 18 Jul 2026 13:25:11 +0300 Subject: [PATCH] feat: add H.264 hardware decoding Co-Authored-By: Claude Opus 4.8 --- .gitignore | 3 + examples/decode_adopted.rs | 133 +++ examples/decode_h264.rs | 108 +++ examples/roundtrip_h264.rs | 142 +++ examples/verify_planes.rs | 345 +++++++ src/decoder/bitreader.rs | 150 +++ src/decoder/codec.rs | 1556 ++++++++++++++++++++++++++++++ src/decoder/h264/dpb.rs | 867 +++++++++++++++++ src/decoder/h264/mod.rs | 47 + src/decoder/h264/parser.rs | 858 ++++++++++++++++ src/decoder/h264/parser_tests.rs | 403 ++++++++ src/decoder/h264/session.rs | 1053 ++++++++++++++++++++ src/decoder/mod.rs | 306 ++++++ src/encoder/av1/init.rs | 15 +- src/encoder/h264/init.rs | 17 +- src/encoder/h265/init.rs | 13 +- src/encoder/pipeline.rs | 1 + src/encoder/resources.rs | 678 ++----------- src/error.rs | 11 + src/lib.rs | 2 + src/video.rs | 610 ++++++++++++ src/vulkan.rs | 470 ++++++++- tests/context.rs | 44 + tests/data/base.264 | Bin 0 -> 9956 bytes tests/data/bframes.264 | Bin 0 -> 7251 bytes tests/data/multislice.264 | Bin 0 -> 8731 bytes tests/data/zerolatency.264 | Bin 0 -> 11485 bytes 27 files changed, 7199 insertions(+), 633 deletions(-) create mode 100644 examples/decode_adopted.rs create mode 100644 examples/decode_h264.rs create mode 100644 examples/roundtrip_h264.rs create mode 100644 examples/verify_planes.rs create mode 100644 src/decoder/bitreader.rs create mode 100644 src/decoder/codec.rs create mode 100644 src/decoder/h264/dpb.rs create mode 100644 src/decoder/h264/mod.rs create mode 100644 src/decoder/h264/parser.rs create mode 100644 src/decoder/h264/parser_tests.rs create mode 100644 src/decoder/h264/session.rs create mode 100644 src/decoder/mod.rs create mode 100644 src/video.rs create mode 100644 tests/context.rs create mode 100644 tests/data/base.264 create mode 100644 tests/data/bframes.264 create mode 100644 tests/data/multislice.264 create mode 100644 tests/data/zerolatency.264 diff --git a/.gitignore b/.gitignore index 7f7920e..e8a8cf3 100644 --- a/.gitignore +++ b/.gitignore @@ -3,3 +3,6 @@ output.* # Generated at runtime by examples/verify_all.rs (ensure_test_data); dimension-keyed. testdata/test_frames_*x*_*.yuv + +# Stray raw decode outputs from manual testing +*.yuv diff --git a/examples/decode_adopted.rs b/examples/decode_adopted.rs new file mode 100644 index 0000000..2380b0f --- /dev/null +++ b/examples/decode_adopted.rs @@ -0,0 +1,133 @@ +//! Decode on a caller-created device. +//! +//! Mirrors what an application with its own Vulkan device does: create the +//! instance and device itself, then hand them to pixelforge via +//! `build_from_existing_decode` so decoded images live on the app's device. +//! +//! Usage: +//! decode_adopted [output.yuv] +//! +//! Output is NV12 in display order, comparable to: +//! ffmpeg -i input.264 -pix_fmt nv12 reference.yuv + +use std::fs::File; +use std::io::Write; + +use ash::vk; +use ash::vk::TaggedStructure; +use pixelforge::decoder::{DecodeConfig, Decoder, access_units}; +use pixelforge::encoder::Codec; +use pixelforge::vulkan::VideoContextBuilder; + +fn main() -> Result<(), Box> { + let mut args = std::env::args().skip(1); + let input_path = args + .next() + .expect("usage: decode_adopted [output.yuv]"); + let output_path = args.next(); + + // --- The application creates its own instance and device --- + let entry = unsafe { ash::Entry::load()? }; + let app_name = c"decode-adopted"; + let app_info = vk::ApplicationInfo::default() + .application_name(app_name) + .api_version(vk::API_VERSION_1_3); + let instance_info = vk::InstanceCreateInfo::default().application_info(&app_info); + let instance = unsafe { entry.create_instance(&instance_info, None)? }; + + // Ask pixelforge what a decode device needs, and pick a physical device that + // satisfies it — exactly the negotiation an app would do. + let builder = VideoContextBuilder::new().require_decode(Codec::H264); + let physical_devices = unsafe { instance.enumerate_physical_devices()? }; + let (physical_device, reqs) = physical_devices + .iter() + .find_map(|&pd| { + builder + .decode_device_requirements(&entry, &instance, pd) + .ok() + .map(|r| (pd, r)) + }) + .expect("no physical device can decode H.264"); + + let name = unsafe { + std::ffi::CStr::from_ptr( + instance + .get_physical_device_properties(physical_device) + .device_name + .as_ptr(), + ) + }; + println!("Device: {}", name.to_string_lossy()); + println!( + "pixelforge needs queue families {:?} and {} extensions", + reqs.queue_families, + reqs.extensions.len() + ); + + // The app merges pixelforge's requirements with its own. Here there is + // nothing else, so we use them directly. + let priorities = [1.0f32]; + let queue_infos: Vec = reqs + .queue_families + .iter() + .map(|&f| { + vk::DeviceQueueCreateInfo::default() + .queue_family_index(f) + .queue_priorities(&priorities) + }) + .collect(); + let ext_ptrs: Vec<*const std::os::raw::c_char> = + reqs.extensions.iter().map(|e| e.as_ptr()).collect(); + + let mut sync2 = vk::PhysicalDeviceSynchronization2Features::default().synchronization2(true); + let device_info = vk::DeviceCreateInfo::default() + .queue_create_infos(&queue_infos) + .enabled_extension_names(&ext_ptrs) + .push(&mut sync2); + let device = unsafe { instance.create_device(physical_device, &device_info, None)? }; + + // --- Hand the app's device to pixelforge --- + let context = builder.build_from_existing_decode( + entry.clone(), + instance.clone(), + physical_device, + device.clone(), + )?; + + let mut decoder = Decoder::new(context, DecodeConfig::h264())?; + let stream = std::fs::read(&input_path)?; + let mut output = output_path.as_ref().map(File::create).transpose()?; + let mut count = 0usize; + + let write = |decoder: &mut Decoder, + frame: &pixelforge::decoder::DecodedFrame, + output: &mut Option, + count: &mut usize| + -> Result<(), Box> { + if let Some(file) = output.as_mut() { + let data = decoder.download(frame)?; + file.write_all(&data.y)?; + file.write_all(&data.uv)?; + } + *count += 1; + Ok(()) + }; + + for (i, au) in access_units(&stream).enumerate() { + for frame in decoder.decode(au, i as u64)? { + write(&mut decoder, &frame, &mut output, &mut count)?; + } + } + for frame in decoder.flush()? { + write(&mut decoder, &frame, &mut output, &mut count)?; + } + println!("Decoded {} frames on the adopted device", count); + + // The context borrowed the device; drop it before we destroy the device. + drop(decoder); + unsafe { + device.destroy_device(None); + instance.destroy_instance(None); + } + Ok(()) +} diff --git a/examples/decode_h264.rs b/examples/decode_h264.rs new file mode 100644 index 0000000..81f03e6 --- /dev/null +++ b/examples/decode_h264.rs @@ -0,0 +1,108 @@ +//! Decode an H.264 Annex B stream to raw YUV. +//! +//! Usage: +//! cargo run --example decode_h264 -- input.264 [output.yuv] +//! +//! Produces the same layout as `ffmpeg -i input.264 -pix_fmt nv12 out.yuv`, +//! so the output can be compared directly: +//! +//! ffmpeg -i input.264 -pix_fmt nv12 reference.yuv +//! cmp reference.yuv output.yuv + +use std::fs::File; +use std::io::Write; + +use pixelforge::decoder::{DecodeConfig, Decoder, access_units}; +use pixelforge::encoder::Codec; +use pixelforge::vulkan::VideoContextBuilder; + +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + let mut args = std::env::args().skip(1); + let input_path = args.next().unwrap_or_else(|| { + eprintln!("usage: decode_h264 [output.yuv]"); + std::process::exit(1); + }); + let output_path = args.next(); + + // PIXELFORGE_VALIDATION=1 enables the Vulkan validation layers, which + // report the exact offending call for any API misuse. + let validation = std::env::var("PIXELFORGE_VALIDATION").is_ok(); + let context = VideoContextBuilder::new() + .app_name("pixelforge-decode") + .require_decode(Codec::H264) + .enable_validation(validation) + .build()?; + + println!( + "Device: {}", + unsafe { std::ffi::CStr::from_ptr(context.device_properties().device_name.as_ptr()) } + .to_string_lossy() + ); + + // Display order by default: frames arrive ready to present, no sorting. + // Set PIXELFORGE_DECODE_ORDER=1 for the low-latency decode-order path. + let config = if std::env::var("PIXELFORGE_DECODE_ORDER").is_ok() { + DecodeConfig::h264().with_decode_order() + } else { + DecodeConfig::h264() + }; + let mut decoder = Decoder::new(context, config)?; + let stream = std::fs::read(&input_path)?; + let mut output = output_path.as_ref().map(File::create).transpose()?; + + let start = std::time::Instant::now(); + let mut frame_count = 0usize; + let mut last_info = None; + + // Write each frame as it comes out. `flush` at the end drains the frames + // the reorder buffer was still holding. + let write = |decoder: &mut Decoder, + frame: &pixelforge::decoder::DecodedFrame, + output: &mut Option| + -> Result<(), Box> { + if let Some(file) = output.as_mut() { + let data = decoder.download(frame)?; + file.write_all(&data.y)?; + file.write_all(&data.uv)?; + } + Ok(()) + }; + + for au in access_units(&stream) { + let decoded = match decoder.decode(au, frame_count as u64) { + Ok(frames) => frames, + // Joining mid-stream (or after loss): skip until a keyframe. A live + // client would ask the sender for an IDR here. + Err(pixelforge::error::PixelForgeError::NeedsKeyframe(_)) => continue, + Err(e) => return Err(e.into()), + }; + for frame in decoded { + write(&mut decoder, &frame, &mut output)?; + last_info = Some((frame.width, frame.height, frame.poc, frame.is_idr)); + frame_count += 1; + } + } + for frame in decoder.flush()? { + write(&mut decoder, &frame, &mut output)?; + last_info = Some((frame.width, frame.height, frame.poc, frame.is_idr)); + frame_count += 1; + } + let decode_time = start.elapsed(); + + println!( + "Decoded {} frames in {:.1?} ({:.1} fps)", + frame_count, + decode_time, + frame_count as f64 / decode_time.as_secs_f64() + ); + if let Some((w, h, poc, idr)) = last_info { + println!("Last frame: {}x{} poc={} idr={}", w, h, poc, idr); + } + if let Some(path) = output_path { + println!("Wrote raw frames to {}", path); + } + + Ok(()) +} diff --git a/examples/roundtrip_h264.rs b/examples/roundtrip_h264.rs new file mode 100644 index 0000000..5084f95 --- /dev/null +++ b/examples/roundtrip_h264.rs @@ -0,0 +1,142 @@ +//! Encode with pixelforge, then decode the result with pixelforge. +//! +//! The decoder must handle anything this project's encoder can produce. That is +//! not covered by decoding third-party streams: the encoder makes its own +//! choices (notably explicit reference marking for B-frames), so it needs its +//! own round trip. +//! +//! Usage: +//! roundtrip_h264 [out.yuv] +//! +//! The input is planar YUV420 (I420). The decoded output is NV12 in display +//! order, so it can be compared against ffmpeg decoding the same bitstream: +//! +//! ffmpeg -i out.h264 -pix_fmt nv12 reference.yuv +//! cmp reference.yuv out.yuv + +use std::collections::VecDeque; +use std::fs::File; +use std::io::{Read, Write}; + +use pixelforge::decoder::{DecodeConfig, Decoder, access_units}; +use pixelforge::{ + Codec, EncodeBitDepth, EncodeConfig, Encoder, InputImage, PixelFormat, RateControlMode, + VideoContextBuilder, +}; + +fn main() -> Result<(), Box> { + tracing_subscriber::fmt::init(); + + let args: Vec = std::env::args().skip(1).collect(); + if args.len() < 6 { + eprintln!( + "usage: roundtrip_h264 [out.yuv]" + ); + std::process::exit(1); + } + let input_path = &args[0]; + let width: u32 = args[1].parse()?; + let height: u32 = args[2].parse()?; + let b_frames: u32 = args[3].parse()?; + let gop: u32 = args[4].parse()?; + let bitstream_path = &args[5]; + let yuv_path = args.get(6); + + let context = VideoContextBuilder::new() + .app_name("pixelforge-roundtrip") + .require_encode(Codec::H264) + .require_decode(Codec::H264) + .enable_validation(std::env::var("PIXELFORGE_VALIDATION").is_ok()) + .build()?; + + // --- Encode --- + let mut yuv = Vec::new(); + File::open(input_path)?.read_to_end(&mut yuv)?; + let frame_size = (width * height * 3 / 2) as usize; + let frame_count = yuv.len() / frame_size; + + let config = EncodeConfig::h264(width, height) + .with_rate_control(RateControlMode::Cqp) + .with_quality_level(26) + .with_frame_rate(30, 1) + .with_gop_size(gop) + .with_b_frames(b_frames); + + let mut input_image = InputImage::new( + context.clone(), + Codec::H264, + width, + height, + EncodeBitDepth::Eight, + PixelFormat::Yuv420, + )?; + let mut encoder = Encoder::new(context.clone(), config)?; + + let mut bitstream = Vec::new(); + let mut pending: VecDeque = VecDeque::new(); + for i in 0..frame_count { + input_image.upload_yuv420(&yuv[i * frame_size..(i + 1) * frame_size])?; + pending.push_back(encoder.encode(input_image.image())?); + while pending.len() > 2 { + let packet = pollster::block_on(pending.pop_front().expect("non-empty"))?; + bitstream.extend_from_slice(&packet.data); + } + } + encoder.flush()?; + while let Some(future) = pending.pop_front() { + let packet = pollster::block_on(future)?; + bitstream.extend_from_slice(&packet.data); + } + File::create(bitstream_path)?.write_all(&bitstream)?; + println!( + "Encoded {} frames -> {} bytes (b_frames={}, gop={})", + frame_count, + bitstream.len(), + b_frames, + gop + ); + + // --- Decode it back --- + // Display order by default, so frames come out ready to write; `flush` + // drains whatever the reorder buffer still holds at end of stream. + let mut decoder = Decoder::new(context, DecodeConfig::h264())?; + let mut out = yuv_path.map(File::create).transpose()?; + let mut decoded_count = 0usize; + + let write = |decoder: &mut Decoder, + frame: &pixelforge::decoder::DecodedFrame, + out: &mut Option, + count: &mut usize| + -> Result<(), Box> { + if let Some(file) = out.as_mut() { + let data = decoder.download(frame)?; + file.write_all(&data.y)?; + file.write_all(&data.uv)?; + } + *count += 1; + Ok(()) + }; + + for au in access_units(&bitstream) { + for frame in decoder.decode(au, decoded_count as u64)? { + write(&mut decoder, &frame, &mut out, &mut decoded_count)?; + } + } + for frame in decoder.flush()? { + write(&mut decoder, &frame, &mut out, &mut decoded_count)?; + } + println!("Decoded {} frames", decoded_count); + + if decoded_count != frame_count { + return Err(format!( + "round trip lost frames: encoded {} but decoded {}", + frame_count, decoded_count + ) + .into()); + } + if yuv_path.is_some() { + println!("Wrote decoded frames to {}", yuv_path.unwrap()); + } + + Ok(()) +} diff --git a/examples/verify_planes.rs b/examples/verify_planes.rs new file mode 100644 index 0000000..bc6dd49 --- /dev/null +++ b/examples/verify_planes.rs @@ -0,0 +1,345 @@ +//! Verify `Decoder::copy_frame_to_planes`: copy each decoded frame into caller +//! Y (R8) and UV (R8G8) images, read them back, reconstruct NV12, and compare +//! to ffmpeg. Uses validation layers to catch any Vulkan misuse in the copy. +//! +//! Usage: verify_planes + +use ash::vk; +use ash::vk::TaggedStructure; +use pixelforge::decoder::{DecodeConfig, Decoder, access_units}; +use pixelforge::vulkan::VideoContextBuilder; + +fn main() -> Result<(), Box> { + let input = std::env::args() + .nth(1) + .expect("usage: verify_planes "); + let out_path = std::env::args() + .nth(2) + .expect("usage: verify_planes "); + + let entry = unsafe { ash::Entry::load()? }; + let layers = [c"VK_LAYER_KHRONOS_validation".as_ptr()]; + let app_info = vk::ApplicationInfo::default().api_version(vk::API_VERSION_1_3); + let instance = unsafe { + entry.create_instance( + &vk::InstanceCreateInfo::default() + .application_info(&app_info) + .enabled_layer_names(&layers), + None, + )? + }; + + let builder = VideoContextBuilder::new().require_decode(pixelforge::Codec::H264); + let (pdevice, reqs) = unsafe { instance.enumerate_physical_devices()? } + .into_iter() + .find_map(|pd| { + builder + .decode_device_requirements(&entry, &instance, pd) + .ok() + .map(|r| (pd, r)) + }) + .expect("no decode-capable device"); + + let priorities = [1.0f32]; + let qinfos: Vec<_> = reqs + .queue_families + .iter() + .map(|&f| { + vk::DeviceQueueCreateInfo::default() + .queue_family_index(f) + .queue_priorities(&priorities) + }) + .collect(); + let exts: Vec<_> = reqs.extensions.iter().map(|e| e.as_ptr()).collect(); + let mut sync2 = vk::PhysicalDeviceSynchronization2Features::default().synchronization2(true); + let device = unsafe { + instance.create_device( + pdevice, + &vk::DeviceCreateInfo::default() + .queue_create_infos(&qinfos) + .enabled_extension_names(&exts) + .push(&mut sync2), + None, + )? + }; + + // A queue + pool for the readback copies (use the transfer family). + let transfer_family = reqs.queue_families[1]; + let queue = unsafe { device.get_device_queue(transfer_family, 0) }; + let pool = unsafe { + device.create_command_pool( + &vk::CommandPoolCreateInfo::default() + .queue_family_index(transfer_family) + .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER), + None, + )? + }; + let mem_props = unsafe { instance.get_physical_device_memory_properties(pdevice) }; + + let context = builder.build_from_existing_decode( + entry.clone(), + instance.clone(), + pdevice, + device.clone(), + )?; + let mut decoder = Decoder::new(context, DecodeConfig::h264())?; + + let stream = std::fs::read(&input)?; + let mut out = std::fs::File::create(&out_path)?; + let mut y_res: Option = None; + let mut uv_res: Option = None; + let mut count = 0; + + let mut handle = |decoder: &mut Decoder, + f: &pixelforge::decoder::DecodedFrame, + out: &mut std::fs::File| + -> Result<(), Box> { + let (cw, ch) = (f.coded_width, f.coded_height); + let y = y_res.get_or_insert_with(|| { + PlaneImg::new(&device, &mem_props, cw, ch, vk::Format::R8_UNORM) + }); + let uv = uv_res.get_or_insert_with(|| { + PlaneImg::new(&device, &mem_props, cw / 2, ch / 2, vk::Format::R8G8_UNORM) + }); + decoder.copy_frame_to_planes(f, y.image, uv.image)?; + + // Read back the visible region of each plane and emit NV12. + let y_bytes = y.read_back(&device, &mem_props, pool, queue, f.width, f.height, 1); + let uv_bytes = uv.read_back( + &device, + &mem_props, + pool, + queue, + f.width / 2, + f.height / 2, + 2, + ); + use std::io::Write; + out.write_all(&y_bytes)?; + out.write_all(&uv_bytes)?; + Ok(()) + }; + + for (i, au) in access_units(&stream).enumerate() { + for f in decoder.decode(au, i as u64)? { + handle(&mut decoder, &f, &mut out)?; + count += 1; + } + } + for f in decoder.flush()? { + handle(&mut decoder, &f, &mut out)?; + count += 1; + } + println!("Copied {count} frames through Y/UV planes"); + + drop(decoder); + unsafe { + if let Some(p) = y_res { + p.destroy(&device); + } + if let Some(p) = uv_res { + p.destroy(&device); + } + device.destroy_command_pool(pool, None); + device.destroy_device(None); + instance.destroy_instance(None); + } + Ok(()) +} + +struct PlaneImg { + image: vk::Image, + memory: vk::DeviceMemory, + width: u32, + height: u32, +} + +impl PlaneImg { + fn new( + device: &ash::Device, + mem_props: &vk::PhysicalDeviceMemoryProperties, + width: u32, + height: u32, + format: vk::Format, + ) -> Self { + let image = unsafe { + device.create_image( + &vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(format) + .extent(vk::Extent3D { + width, + height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::TRANSFER_SRC) + .initial_layout(vk::ImageLayout::UNDEFINED), + None, + ) + } + .unwrap(); + let reqs = unsafe { device.get_image_memory_requirements(image) }; + let mem_type = (0..mem_props.memory_type_count) + .find(|&i| { + reqs.memory_type_bits & (1 << i) != 0 + && mem_props.memory_types[i as usize] + .property_flags + .contains(vk::MemoryPropertyFlags::DEVICE_LOCAL) + }) + .unwrap(); + let memory = unsafe { + device.allocate_memory( + &vk::MemoryAllocateInfo::default() + .allocation_size(reqs.size) + .memory_type_index(mem_type), + None, + ) + } + .unwrap(); + unsafe { device.bind_image_memory(image, memory, 0).unwrap() }; + Self { + image, + memory, + width, + height, + } + } + + /// Copy the top-left `w`x`h` region (texel size `bpp`) to the host. + fn read_back( + &self, + device: &ash::Device, + mem_props: &vk::PhysicalDeviceMemoryProperties, + pool: vk::CommandPool, + queue: vk::Queue, + w: u32, + h: u32, + bpp: u32, + ) -> Vec { + let size = (self.width * self.height * bpp) as u64; + // Host-visible staging buffer. + let buf = unsafe { + device.create_buffer( + &vk::BufferCreateInfo::default() + .size(size) + .usage(vk::BufferUsageFlags::TRANSFER_DST), + None, + ) + } + .unwrap(); + let reqs = unsafe { device.get_buffer_memory_requirements(buf) }; + let mem_type = (0..mem_props.memory_type_count) + .find(|&i| { + reqs.memory_type_bits & (1 << i) != 0 + && mem_props.memory_types[i as usize].property_flags.contains( + vk::MemoryPropertyFlags::HOST_VISIBLE + | vk::MemoryPropertyFlags::HOST_COHERENT, + ) + }) + .expect("host-visible memory"); + let mem = unsafe { + device.allocate_memory( + &vk::MemoryAllocateInfo::default() + .allocation_size(reqs.size) + .memory_type_index(mem_type), + None, + ) + } + .unwrap(); + unsafe { device.bind_buffer_memory(buf, mem, 0).unwrap() }; + + let cmd = unsafe { + device.allocate_command_buffers( + &vk::CommandBufferAllocateInfo::default() + .command_pool(pool) + .command_buffer_count(1), + ) + } + .unwrap()[0]; + unsafe { + device + .begin_command_buffer( + cmd, + &vk::CommandBufferBeginInfo::default() + .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), + ) + .unwrap(); + let to_src = vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::NONE) + .dst_stage_mask(vk::PipelineStageFlags2::COPY) + .dst_access_mask(vk::AccessFlags2::TRANSFER_READ) + .old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .image(self.image) + .subresource_range(vk::ImageSubresourceRange { + aspect_mask: vk::ImageAspectFlags::COLOR, + base_mip_level: 0, + level_count: 1, + base_array_layer: 0, + layer_count: 1, + }); + device.cmd_pipeline_barrier2( + cmd, + &vk::DependencyInfo::default().image_memory_barriers(std::slice::from_ref(&to_src)), + ); + let region = vk::BufferImageCopy2::default() + .buffer_row_length(self.width) + .buffer_image_height(self.height) + .image_subresource(vk::ImageSubresourceLayers { + aspect_mask: vk::ImageAspectFlags::COLOR, + mip_level: 0, + base_array_layer: 0, + layer_count: 1, + }) + .image_extent(vk::Extent3D { + width: w, + height: h, + depth: 1, + }); + device.cmd_copy_image_to_buffer2( + cmd, + &vk::CopyImageToBufferInfo2::default() + .src_image(self.image) + .src_image_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .dst_buffer(buf) + .regions(std::slice::from_ref(®ion)), + ); + device.end_command_buffer(cmd).unwrap(); + let cbs = [cmd]; + let submit = vk::SubmitInfo::default().command_buffers(&cbs); + device + .queue_submit(queue, &[submit], vk::Fence::null()) + .unwrap(); + device.queue_wait_idle(queue).unwrap(); + + let ptr = device + .map_memory(mem, 0, size, vk::MemoryMapFlags::empty()) + .unwrap() as *const u8; + // Rows are buffer_row_length (coded) wide; take the visible w*bpp per row. + let stride = (self.width * bpp) as usize; + let mut out = Vec::with_capacity((w * h * bpp) as usize); + for row in 0..h as usize { + let s = ptr.add(row * stride); + out.extend_from_slice(std::slice::from_raw_parts(s, (w * bpp) as usize)); + } + device.unmap_memory(mem); + device.free_command_buffers(pool, &[cmd]); + device.destroy_buffer(buf, None); + device.free_memory(mem, None); + out + } + } + + fn destroy(self, device: &ash::Device) { + unsafe { + device.destroy_image(self.image, None); + device.free_memory(self.memory, None); + } + } +} diff --git a/src/decoder/bitreader.rs b/src/decoder/bitreader.rs new file mode 100644 index 0000000..07a46a3 --- /dev/null +++ b/src/decoder/bitreader.rs @@ -0,0 +1,150 @@ +//! A bit reader for parsing codec bitstream syntax (Exp-Golomb, fixed-width). +//! +//! Operates on the *encapsulated* byte payload (EBSP) of a NAL unit and strips +//! `0x03` emulation-prevention bytes on the fly, so callers can hand it a raw +//! NAL payload without first converting to RBSP. + +use crate::error::{PixelForgeError, Result}; + +/// Reads bits MSB-first from a NAL payload, skipping emulation-prevention bytes. +pub(crate) struct BitReader<'a> { + data: &'a [u8], + /// Index of the next byte to load. + byte_pos: usize, + /// Bit offset (0..8) within the current byte. + bit_pos: u32, + /// Number of consecutive zero bytes seen (for emulation prevention). + zero_run: u32, +} + +impl<'a> BitReader<'a> { + pub fn new(data: &'a [u8]) -> Self { + Self { + data, + byte_pos: 0, + bit_pos: 0, + zero_run: 0, + } + } + + /// Read a single bit. + pub fn bit(&mut self) -> Result { + if self.bit_pos == 0 { + // About to start a new byte: skip an emulation prevention byte + // (0x03 after two zero bytes). + if self.zero_run >= 2 + && self.byte_pos < self.data.len() + && self.data[self.byte_pos] == 0x03 + { + self.byte_pos += 1; + self.zero_run = 0; + } + if self.byte_pos >= self.data.len() { + return Err(PixelForgeError::InvalidInput( + "bitstream: read past end of NAL unit".to_string(), + )); + } + if self.data[self.byte_pos] == 0 { + self.zero_run += 1; + } else { + self.zero_run = 0; + } + } + let byte = self.data[self.byte_pos]; + let bit = (byte >> (7 - self.bit_pos)) & 1; + self.bit_pos += 1; + if self.bit_pos == 8 { + self.bit_pos = 0; + self.byte_pos += 1; + } + Ok(bit as u32) + } + + /// Read `n` bits (n <= 32) as an unsigned value. `u(n)` in spec notation. + pub fn bits(&mut self, n: u32) -> Result { + debug_assert!(n <= 32); + let mut v = 0u32; + for _ in 0..n { + v = (v << 1) | self.bit()?; + } + Ok(v) + } + + /// Read a boolean flag. `u(1)`. + pub fn flag(&mut self) -> Result { + Ok(self.bit()? != 0) + } + + /// Read an unsigned Exp-Golomb value. `ue(v)`. + pub fn ue(&mut self) -> Result { + let mut leading_zeros = 0u32; + while self.bit()? == 0 { + leading_zeros += 1; + if leading_zeros > 31 { + return Err(PixelForgeError::InvalidInput( + "bitstream: invalid Exp-Golomb code".to_string(), + )); + } + } + if leading_zeros == 0 { + return Ok(0); + } + let suffix = self.bits(leading_zeros)?; + Ok((1u32 << leading_zeros) - 1 + suffix) + } + + /// Read a signed Exp-Golomb value. `se(v)`. + pub fn se(&mut self) -> Result { + let code = self.ue()?; + // Mapping: 0 -> 0, 1 -> 1, 2 -> -1, 3 -> 2, 4 -> -2, ... + let value = code.div_ceil(2) as i32; + if code % 2 == 0 { Ok(-value) } else { Ok(value) } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bits() { + let data = [0b1010_1100, 0b0111_0001]; + let mut r = BitReader::new(&data); + assert_eq!(r.bits(4).unwrap(), 0b1010); + assert_eq!(r.bits(8).unwrap(), 0b1100_0111); + assert_eq!(r.bits(4).unwrap(), 0b0001); + assert!(r.bit().is_err()); + } + + #[test] + fn test_ue() { + // ue codes: 1 -> 0, 010 -> 1, 011 -> 2, 00100 -> 3 + let data = [0b1_010_011_0, 0b0100_0000]; + let mut r = BitReader::new(&data); + assert_eq!(r.ue().unwrap(), 0); + assert_eq!(r.ue().unwrap(), 1); + assert_eq!(r.ue().unwrap(), 2); + assert_eq!(r.ue().unwrap(), 3); + } + + #[test] + fn test_se() { + // se: code 1 -> 0, 010 -> +1, 011 -> -1 + let data = [0b1_010_011_0]; + let mut r = BitReader::new(&data); + assert_eq!(r.se().unwrap(), 0); + assert_eq!(r.se().unwrap(), 1); + assert_eq!(r.se().unwrap(), -1); + } + + #[test] + fn test_emulation_prevention() { + // 0x00 0x00 0x03 0x01 -> RBSP bytes 0x00 0x00 0x01 + let data = [0x00, 0x00, 0x03, 0x01]; + let mut r = BitReader::new(&data); + assert_eq!(r.bits(8).unwrap(), 0x00); + assert_eq!(r.bits(8).unwrap(), 0x00); + assert_eq!(r.bits(8).unwrap(), 0x01); + assert!(r.bits(8).is_err()); + } +} diff --git a/src/decoder/codec.rs b/src/decoder/codec.rs new file mode 100644 index 0000000..7666381 --- /dev/null +++ b/src/decoder/codec.rs @@ -0,0 +1,1556 @@ +//! The codec-generic decoder. +//! +//! Everything that is identical across H.264, H.265 and AV1 lives here: the +//! shared per-decoder state ([`DecoderCommon`]), the Vulkan video session and +//! its DPB images ([`DecodeSession`]), coded-data staging, and the readback +//! path that copies a decoded picture back to the host. +//! +//! A codec supplies only its differences: parsing its bitstream into pictures, +//! reconstructing the reference state the encoder implied, and building the +//! codec-specific `StdVideo*` graph for a picture. Reading a codec's folder +//! shows those differences; the scaffolding is here. +//! +//! There is deliberately no `VideoDecodeCodec` trait yet. The per-picture hook +//! shape is only knowable once a second codec exists to constrain it; H.265 is +//! the forcing function. Until then a codec owns a `DecoderCommon` directly. +//! +//! This mirrors the encoder's [`crate::encoder::codec`] split deliberately, so +//! the two directions read the same way. + +use ash::vk; + +use crate::decoder::{DecodedFrame, DecodedFrameData}; +use crate::encoder::{BitDepth, PixelFormat}; +use crate::error::{PixelForgeError, Result}; +use crate::video::{ + allocate_session_memory, create_bitstream_buffer, create_dpb_images, create_video_image, + find_memory_type, map_bitstream_buffer, +}; +use crate::vulkan::VideoContext; + +/// Initial size of the coded-data staging buffer; grows as needed. +const INITIAL_BITSTREAM_BUFFER_SIZE: usize = 1024 * 1024; + +/// Annex B start code prefixed to each slice handed to the driver. +pub(crate) const START_CODE: [u8; 4] = [0, 0, 0, 1]; + +/// Query decode capabilities for a profile. +/// +/// `caps` is supplied by the caller already chained with the codec-specific +/// capability struct, since only the codec knows which one applies. +pub(crate) fn query_decode_caps( + context: &VideoContext, + profile_info: &vk::VideoProfileInfoKHR, + caps: &mut vk::VideoCapabilitiesKHR, +) -> Result<()> { + let video_queue_instance = + ash::khr::video_queue::Instance::load(context.entry(), context.instance()); + let result = unsafe { + (video_queue_instance + .fp() + .get_physical_device_video_capabilities_khr)( + context.physical_device(), + profile_info, + caps, + ) + }; + if result != vk::Result::SUCCESS { + return Err(PixelForgeError::CodecNotSupported(format!( + "decode profile not supported: {:?}", + result + ))); + } + Ok(()) +} + +/// What a codec decided the session should look like. +/// +/// The codec derives these from its own parameter sets and the device caps; the +/// generic layer turns them into Vulkan objects. +pub(crate) struct SessionPlan { + /// Coded dimensions, already aligned to the device's picture granularity. + pub coded_width: u32, + pub coded_height: u32, + pub picture_format: vk::Format, + pub bit_depth: BitDepth, + pub pixel_format: PixelFormat, + /// DPB slots: references plus the picture being decoded. + pub slot_count: usize, + /// Driver decodes straight into the DPB image (`DPB_AND_OUTPUT_COINCIDE`). + pub coincide: bool, + /// One layered DPB image rather than one image per slot. + pub use_layered_dpb: bool, + pub dpb_usage: vk::ImageUsageFlags, +} + +/// The Vulkan video session and the images it decodes into. +/// +/// Recreated whenever the stream's parameter sets change in a way that matters +/// (a new resolution, most often). +pub(crate) struct DecodeSession { + pub session: vk::VideoSessionKHR, + pub session_memory: Vec, + pub session_params: vk::VideoSessionParametersKHR, + + /// Coded dimensions the session was created for. + pub coded_width: u32, + pub coded_height: u32, + pub picture_format: vk::Format, + pub bit_depth: BitDepth, + pub pixel_format: PixelFormat, + + /// DPB images. One image per slot, or a single layered image. + pub dpb_images: Vec, + pub dpb_memories: Vec, + /// One view per slot (indexes slots even when layered). + pub dpb_views: Vec, + /// Whether each slot has been written at least once (governs the + /// UNDEFINED-vs-DPB old layout in the pre-decode barrier). + pub dpb_slot_active: Vec, + pub use_layered_dpb: bool, + + /// True when the driver decodes into the DPB image directly; false when a + /// distinct output image is required. + pub coincide: bool, + /// Distinct decode output image, only when `!coincide`. + pub output_image: Option<(vk::Image, vk::DeviceMemory, vk::ImageView)>, +} + +/// Per-decoder state shared by every codec. +/// +/// Holds the Vulkan video session, the DPB images, the coded-data staging +/// buffer and the readback path — none of which differ between codecs. +/// Codec-specific state (parameter sets, POC, reference marking) lives in the +/// codec instead. +pub(crate) struct DecoderCommon { + pub context: VideoContext, + pub video_queue_fn: ash::khr::video_queue::Device, + pub video_decode_fn: ash::khr::video_decode_queue::Device, + pub decode_queue: vk::Queue, + pub decode_queue_family: u32, + + pub command_pool: vk::CommandPool, + pub command_buffer: vk::CommandBuffer, + pub fence: vk::Fence, + + /// Readback resources. The video decode queue family is a dedicated engine + /// that does not advertise `TRANSFER_BIT` (true on RADV, among others), so + /// copies must be recorded and submitted on the transfer queue instead. + pub transfer_queue: vk::Queue, + pub transfer_pool: vk::CommandPool, + pub transfer_command_buffer: vk::CommandBuffer, + pub transfer_fence: vk::Fence, + + /// Staging buffer for coded data handed to the decode queue. + pub bitstream_buffer: vk::Buffer, + pub bitstream_memory: vk::DeviceMemory, + pub bitstream_ptr: *mut u8, + pub bitstream_size: usize, + /// Required alignment of bitstream buffer offsets/ranges. + pub bitstream_offset_alignment: u64, + pub bitstream_size_alignment: u64, + + /// Readback buffer for `download`, allocated on first use. + pub readback: Option<(vk::Buffer, vk::DeviceMemory, usize)>, + + /// The active session, created lazily from the stream's parameter sets. + pub session: Option, +} + +// `bitstream_ptr` is a persistently mapped host-visible allocation owned by +// this struct; it is never aliased or shared across threads. +unsafe impl Send for DecoderCommon {} + +impl DecoderCommon { + pub fn new(context: VideoContext) -> Result { + let decode_queue_family = context.video_decode_queue_family().ok_or_else(|| { + PixelForgeError::NoSuitableDevice("Device has no video decode queue family".to_string()) + })?; + let decode_queue = context.video_decode_queue().ok_or_else(|| { + PixelForgeError::NoSuitableDevice("Device has no video decode queue".to_string()) + })?; + + let video_queue_fn = + ash::khr::video_queue::Device::load(context.instance(), context.device()); + let video_decode_fn = + ash::khr::video_decode_queue::Device::load(context.instance(), context.device()); + + let (command_pool, command_buffer, fence) = + create_command_resources(&context, decode_queue_family, "decode")?; + let (transfer_pool, transfer_command_buffer, transfer_fence) = + create_command_resources(&context, context.transfer_queue_family(), "decode transfer")?; + + Ok(Self { + transfer_queue: context.transfer_queue(), + context, + video_queue_fn, + video_decode_fn, + decode_queue, + decode_queue_family, + command_pool, + command_buffer, + fence, + transfer_pool, + transfer_command_buffer, + transfer_fence, + bitstream_buffer: vk::Buffer::null(), + bitstream_memory: vk::DeviceMemory::null(), + bitstream_ptr: std::ptr::null_mut(), + bitstream_size: 0, + bitstream_offset_alignment: 1, + bitstream_size_alignment: 1, + readback: None, + session: None, + }) + } + + /// The active session, or an error if no parameter sets have been seen yet. + pub fn session(&self) -> Result<&DecodeSession> { + self.session + .as_ref() + .ok_or_else(|| PixelForgeError::InvalidInput("decode: no active session".to_string())) + } + + /// Create the session, its DPB images and (when needed) a distinct output + /// image, replacing any existing one. + /// + /// `make_params` builds the codec's session parameters once the session + /// exists, since parameter sets are entirely codec-specific. + pub fn create_session( + &mut self, + plan: &SessionPlan, + profile_info: &vk::VideoProfileInfoKHR, + std_header_version: &vk::ExtensionProperties, + make_params: impl FnOnce(&Self, vk::VideoSessionKHR) -> Result, + ) -> Result<()> { + self.destroy_session(); + + let session_create_info = vk::VideoSessionCreateInfoKHR::default() + .queue_family_index(self.decode_queue_family) + .flags(vk::VideoSessionCreateFlagsKHR::empty()) + .video_profile(profile_info) + .picture_format(plan.picture_format) + .max_coded_extent(vk::Extent2D { + width: plan.coded_width, + height: plan.coded_height, + }) + .reference_picture_format(plan.picture_format) + .max_dpb_slots(plan.slot_count as u32) + .max_active_reference_pictures(plan.slot_count as u32 - 1) + .std_header_version(std_header_version); + + let session = unsafe { + self.video_queue_fn + .create_video_session(&session_create_info, None) + } + .map_err(|e| PixelForgeError::VideoSessionCreation(format!("{:?}", e)))?; + + let session_memory = allocate_session_memory(&self.context, session, &self.video_queue_fn)?; + + // When the DPB doubles as the decode output it may also be copied from, + // so the transfer queue needs access too. + let families = [ + self.decode_queue_family, + self.context.transfer_queue_family(), + ]; + let (dpb_images, dpb_memories, dpb_views) = create_dpb_images( + &self.context, + plan.coded_width, + plan.coded_height, + plan.picture_format, + plan.slot_count, + plan.dpb_usage, + &families, + profile_info, + plan.use_layered_dpb, + )?; + + // Distinct output image when the driver can't decode straight into the DPB. + let output_image = if plan.coincide { + None + } else { + let usage = + vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR | vk::ImageUsageFlags::TRANSFER_SRC; + Some(create_video_image( + &self.context, + plan.coded_width, + plan.coded_height, + plan.picture_format, + usage, + &families, + profile_info, + )?) + }; + + let session_params = make_params(self, session)?; + + self.session = Some(DecodeSession { + session, + session_memory, + session_params, + coded_width: plan.coded_width, + coded_height: plan.coded_height, + picture_format: plan.picture_format, + bit_depth: plan.bit_depth, + pixel_format: plan.pixel_format, + dpb_images, + dpb_memories, + dpb_views, + dpb_slot_active: vec![false; plan.slot_count], + use_layered_dpb: plan.use_layered_dpb, + coincide: plan.coincide, + output_image, + }); + Ok(()) + } + + pub fn destroy_session(&mut self) { + let Some(session) = self.session.take() else { + return; + }; + unsafe { + self.context.device().device_wait_idle().ok(); + self.video_queue_fn + .destroy_video_session_parameters(session.session_params, None); + for &view in &session.dpb_views { + self.context.device().destroy_image_view(view, None); + } + for &image in &session.dpb_images { + self.context.device().destroy_image(image, None); + } + for &memory in &session.dpb_memories { + self.context.device().free_memory(memory, None); + } + if let Some((image, memory, view)) = session.output_image { + self.context.device().destroy_image_view(view, None); + self.context.device().destroy_image(image, None); + self.context.device().free_memory(memory, None); + } + self.video_queue_fn + .destroy_video_session(session.session, None); + for &memory in &session.session_memory { + self.context.device().free_memory(memory, None); + } + } + } + + /// Ensure the coded-data staging buffer holds at least `size` bytes. + pub fn ensure_bitstream_capacity( + &mut self, + size: usize, + profile_info: &vk::VideoProfileInfoKHR, + ) -> Result<()> { + if self.bitstream_size >= size && self.bitstream_buffer != vk::Buffer::null() { + return Ok(()); + } + let new_size = size.max(INITIAL_BITSTREAM_BUFFER_SIZE).next_power_of_two(); + + if self.bitstream_buffer != vk::Buffer::null() { + unsafe { + self.context.device().device_wait_idle().ok(); + self.context.device().unmap_memory(self.bitstream_memory); + self.context + .device() + .destroy_buffer(self.bitstream_buffer, None); + self.context + .device() + .free_memory(self.bitstream_memory, None); + } + } + + let (buffer, memory) = create_bitstream_buffer( + &self.context, + new_size, + vk::BufferUsageFlags::VIDEO_DECODE_SRC_KHR, + profile_info, + )?; + self.bitstream_ptr = map_bitstream_buffer(&self.context, memory, new_size)?; + self.bitstream_buffer = buffer; + self.bitstream_memory = memory; + self.bitstream_size = new_size; + Ok(()) + } + + /// Copy a picture's slices into the staging buffer, each prefixed with a + /// start code, and report the buffer range plus each slice's offset. + pub fn stage_slices( + &mut self, + slices: &[&[u8]], + profile_info: &vk::VideoProfileInfoKHR, + ) -> Result<(u64, Vec)> { + let total: usize = slices.iter().map(|s| s.len() + START_CODE.len()).sum(); + let aligned_total = + crate::video::align_up(total as u32, self.bitstream_size_alignment as u32) as usize; + self.ensure_bitstream_capacity(aligned_total, profile_info)?; + + let mut offsets = Vec::with_capacity(slices.len()); + let mut cursor = 0usize; + for slice in slices { + offsets.push(cursor as u32); + unsafe { + std::ptr::copy_nonoverlapping( + START_CODE.as_ptr(), + self.bitstream_ptr.add(cursor), + START_CODE.len(), + ); + cursor += START_CODE.len(); + std::ptr::copy_nonoverlapping( + slice.as_ptr(), + self.bitstream_ptr.add(cursor), + slice.len(), + ); + cursor += slice.len(); + } + } + // Zero the alignment padding so the driver never reads stale bytes. + if aligned_total > cursor { + unsafe { + std::ptr::write_bytes(self.bitstream_ptr.add(cursor), 0, aligned_total - cursor); + } + } + Ok((aligned_total as u64, offsets)) + } + + /// Transition the DPB slot this decode writes into, and the output image + /// (if distinct), into the layouts `vkCmdDecodeVideo` requires. + pub fn record_barriers(&self, slot: u8) { + let session = self.session.as_ref().expect("active session"); + let device = self.context.device(); + let mut barriers: Vec = Vec::new(); + + let subresource = |layer: u32| vk::ImageSubresourceRange { + aspect_mask: vk::ImageAspectFlags::COLOR, + base_mip_level: 0, + level_count: 1, + base_array_layer: layer, + layer_count: 1, + }; + + // The slot being written: UNDEFINED on first use, DPB afterwards. + let (dst_image, dst_layer) = if session.use_layered_dpb { + (session.dpb_images[0], slot as u32) + } else { + (session.dpb_images[slot as usize], 0) + }; + let old_layout = if session.dpb_slot_active[slot as usize] { + vk::ImageLayout::VIDEO_DECODE_DPB_KHR + } else { + vk::ImageLayout::UNDEFINED + }; + barriers.push( + vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::VIDEO_DECODE_KHR) + .src_access_mask(vk::AccessFlags2::VIDEO_DECODE_READ_KHR) + .dst_stage_mask(vk::PipelineStageFlags2::VIDEO_DECODE_KHR) + .dst_access_mask(vk::AccessFlags2::VIDEO_DECODE_WRITE_KHR) + .old_layout(old_layout) + .new_layout(vk::ImageLayout::VIDEO_DECODE_DPB_KHR) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .image(dst_image) + .subresource_range(subresource(dst_layer)), + ); + + // A distinct output image must be in decode-DST layout. + if let Some((image, _, _)) = session.output_image { + barriers.push( + vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::ALL_COMMANDS) + .src_access_mask(vk::AccessFlags2::empty()) + .dst_stage_mask(vk::PipelineStageFlags2::VIDEO_DECODE_KHR) + .dst_access_mask(vk::AccessFlags2::VIDEO_DECODE_WRITE_KHR) + .old_layout(vk::ImageLayout::UNDEFINED) + .new_layout(vk::ImageLayout::VIDEO_DECODE_DST_KHR) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .image(image) + .subresource_range(subresource(0)), + ); + } + + let dependency = vk::DependencyInfo::default().image_memory_barriers(&barriers); + unsafe { device.cmd_pipeline_barrier2(self.command_buffer, &dependency) }; + } + + /// Begin recording this picture's decode command buffer. + pub fn begin_decode_commands(&self) -> Result<()> { + let device = self.context.device(); + unsafe { + device + .reset_command_buffer(self.command_buffer, vk::CommandBufferResetFlags::empty()) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + let begin_info = vk::CommandBufferBeginInfo::default() + .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT); + device + .begin_command_buffer(self.command_buffer, &begin_info) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + } + Ok(()) + } + + /// The array layer `frame.image` holds the picture in. + fn frame_array_layer(&self, frame: &DecodedFrame) -> u32 { + let session = self.session.as_ref().expect("active session"); + if !session.use_layered_dpb || !session.coincide { + return 0; + } + // Layered + coincide: find the slot whose view matches. + session + .dpb_views + .iter() + .position(|&v| v == frame.image_view) + .unwrap_or(0) as u32 + } + + /// Ensure the readback buffer holds at least `size` bytes. + /// + /// The stored capacity is the *allocated* size (which the driver may round + /// up), so that `download` can map the whole allocation rather than a + /// sub-range that might violate `nonCoherentAtomSize`. + fn ensure_readback_capacity(&mut self, size: usize) -> Result<()> { + if let Some((_, _, capacity)) = self.readback + && capacity >= size + { + return Ok(()); + } + if let Some((buffer, memory, _)) = self.readback.take() { + unsafe { + self.context.device().device_wait_idle().ok(); + self.context.device().destroy_buffer(buffer, None); + self.context.device().free_memory(memory, None); + } + } + + let create_info = vk::BufferCreateInfo::default() + .size(size as u64) + .usage(vk::BufferUsageFlags::TRANSFER_DST) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + let buffer = unsafe { self.context.device().create_buffer(&create_info, None) } + .map_err(|e| PixelForgeError::ResourceCreation(format!("readback buffer: {}", e)))?; + let reqs = unsafe { self.context.device().get_buffer_memory_requirements(buffer) }; + let memory_type_index = find_memory_type( + self.context.memory_properties(), + reqs.memory_type_bits, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, + ) + .ok_or_else(|| { + PixelForgeError::MemoryAllocation("No host-visible memory for readback".to_string()) + })?; + let alloc_info = vk::MemoryAllocateInfo::default() + .allocation_size(reqs.size) + .memory_type_index(memory_type_index); + let memory = unsafe { self.context.device().allocate_memory(&alloc_info, None) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + unsafe { self.context.device().bind_buffer_memory(buffer, memory, 0) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + + // Record the allocated size, not the requested one. + self.readback = Some((buffer, memory, reqs.size as usize)); + Ok(()) + } + + /// Copy a decoded picture back to the host, cropped to its visible region. + /// + /// Entirely codec-independent: it copies planes out of an image the decode + /// queue has already finished writing. + /// + /// The copy runs on the transfer queue. The video decode queue family is a + /// dedicated engine that need not advertise `TRANSFER_BIT` (it does not on + /// RADV), so recording a copy there is invalid. + pub fn download(&mut self, frame: &DecodedFrame) -> Result { + let (bit_depth, pixel_format) = { + let session = self.session()?; + (session.bit_depth, session.pixel_format) + }; + + let geom = PlaneGeometry::new( + frame.coded_width, + frame.coded_height, + frame.width, + frame.height, + bit_depth, + pixel_format, + ); + let PlaneGeometry { + chroma_div, + y_stride, + y_size, + uv_stride, + total, + .. + } = geom; + + self.ensure_readback_capacity(total)?; + let (buffer, memory, allocated) = self.readback.expect("ensured above"); + + let device = self.context.device().clone(); + unsafe { + device + .reset_command_buffer( + self.transfer_command_buffer, + vk::CommandBufferResetFlags::empty(), + ) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + device + .begin_command_buffer( + self.transfer_command_buffer, + &vk::CommandBufferBeginInfo::default() + .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), + ) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + } + + let base_layer = self.frame_array_layer(frame); + + // A multi-planar image must be transitioned with the plane aspects that + // the copy will use; a COLOR barrier does not cover PLANE_0/PLANE_1 and + // leaves the planes in an undefined layout. + let copy_aspects = vk::ImageAspectFlags::PLANE_0 | vk::ImageAspectFlags::PLANE_1; + + // Recorded on the transfer queue, so no VIDEO_DECODE_* stage or access + // may appear here. The decode is already fence-waited, so the writes are + // visible and NONE is a correct source scope. + let to_src = vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::NONE) + .src_access_mask(vk::AccessFlags2::NONE) + .dst_stage_mask(vk::PipelineStageFlags2::COPY) + .dst_access_mask(vk::AccessFlags2::TRANSFER_READ) + .old_layout(frame.layout) + .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .image(frame.image) + .subresource_range(vk::ImageSubresourceRange { + aspect_mask: copy_aspects, + base_mip_level: 0, + level_count: 1, + base_array_layer: base_layer, + layer_count: 1, + }); + let barriers = [to_src]; + let dependency = vk::DependencyInfo::default().image_memory_barriers(&barriers); + unsafe { device.cmd_pipeline_barrier2(self.transfer_command_buffer, &dependency) }; + + let regions = [ + vk::BufferImageCopy2::default() + .buffer_offset(0) + .buffer_row_length(frame.coded_width) + .buffer_image_height(frame.coded_height) + .image_subresource(vk::ImageSubresourceLayers { + aspect_mask: vk::ImageAspectFlags::PLANE_0, + mip_level: 0, + base_array_layer: base_layer, + layer_count: 1, + }) + .image_offset(vk::Offset3D { x: 0, y: 0, z: 0 }) + .image_extent(vk::Extent3D { + width: frame.coded_width, + height: frame.coded_height, + depth: 1, + }), + vk::BufferImageCopy2::default() + .buffer_offset(y_size as u64) + .buffer_row_length(frame.coded_width / chroma_div as u32) + .buffer_image_height(frame.coded_height / chroma_div as u32) + .image_subresource(vk::ImageSubresourceLayers { + aspect_mask: vk::ImageAspectFlags::PLANE_1, + mip_level: 0, + base_array_layer: base_layer, + layer_count: 1, + }) + .image_offset(vk::Offset3D { x: 0, y: 0, z: 0 }) + .image_extent(vk::Extent3D { + width: frame.coded_width / chroma_div as u32, + height: frame.coded_height / chroma_div as u32, + depth: 1, + }), + ]; + let copy_info = vk::CopyImageToBufferInfo2::default() + .src_image(frame.image) + .src_image_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .dst_buffer(buffer) + .regions(®ions); + unsafe { device.cmd_copy_image_to_buffer2(self.transfer_command_buffer, ©_info) }; + + // Restore the layout so a DPB image stays usable as a reference. (For a + // separate output image this is redundant but harmless: the next decode + // re-transitions it from UNDEFINED.) + let restore = vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::COPY) + .src_access_mask(vk::AccessFlags2::TRANSFER_READ) + .dst_stage_mask(vk::PipelineStageFlags2::NONE) + .dst_access_mask(vk::AccessFlags2::NONE) + .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .new_layout(frame.layout) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .image(frame.image) + .subresource_range(vk::ImageSubresourceRange { + aspect_mask: copy_aspects, + base_mip_level: 0, + level_count: 1, + base_array_layer: base_layer, + layer_count: 1, + }); + let barriers = [restore]; + let dependency = vk::DependencyInfo::default().image_memory_barriers(&barriers); + unsafe { device.cmd_pipeline_barrier2(self.transfer_command_buffer, &dependency) }; + + unsafe { + device + .end_command_buffer(self.transfer_command_buffer) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + let command_buffers = [self.transfer_command_buffer]; + let submit = vk::SubmitInfo::default().command_buffers(&command_buffers); + device + .reset_fences(&[self.transfer_fence]) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + device + .queue_submit(self.transfer_queue, &[submit], self.transfer_fence) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + device + .wait_for_fences(&[self.transfer_fence], true, u64::MAX) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + } + + // Read out, cropping to the visible region. + // Map the whole allocation: a sub-range map must respect + // nonCoherentAtomSize, and WHOLE_SIZE sidesteps that entirely. + let ptr = + unsafe { device.map_memory(memory, 0, vk::WHOLE_SIZE, vk::MemoryMapFlags::empty()) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))? + as *const u8; + debug_assert!( + allocated >= geom.max_read_offset(), + "readback buffer too small: host reads up to {} but only {} bytes are mapped", + geom.max_read_offset(), + allocated + ); + + let visible_y_stride = geom.visible_y_stride; + let visible_uv_stride = geom.visible_uv_stride; + let mut y = Vec::with_capacity(visible_y_stride * geom.visible_y_rows); + let mut uv = Vec::with_capacity(visible_uv_stride * geom.visible_uv_rows); + unsafe { + for row in 0..geom.visible_y_rows { + let src = ptr.add(row * y_stride); + y.extend_from_slice(std::slice::from_raw_parts(src, visible_y_stride)); + } + for row in 0..geom.visible_uv_rows { + let src = ptr.add(y_size + row * uv_stride); + uv.extend_from_slice(std::slice::from_raw_parts(src, visible_uv_stride)); + } + device.unmap_memory(memory); + } + + Ok(DecodedFrameData { + y, + uv, + y_stride: visible_y_stride, + uv_stride: visible_uv_stride, + width: frame.width, + height: frame.height, + bit_depth, + pixel_format, + }) + } + + /// Submit the recorded decode and wait for it to complete. + pub fn submit_decode(&self) -> Result<()> { + let device = self.context.device(); + unsafe { + device + .end_command_buffer(self.command_buffer) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + let command_buffers = [self.command_buffer]; + let submit = vk::SubmitInfo::default().command_buffers(&command_buffers); + device + .reset_fences(&[self.fence]) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + device + .queue_submit(self.decode_queue, &[submit], self.fence) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + device + .wait_for_fences(&[self.fence], true, u64::MAX) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + } + Ok(()) + } + + /// Copy a decoded picture into a caller-owned image, so it survives past the + /// next `decode` call. Used by the reorder buffer to retain frames while + /// later pictures are decoded ahead of them in display order. + /// + /// Runs on the transfer queue, mirroring `download`: the decode is already + /// fence-waited, so `NONE` is a correct source scope. The source's layout is + /// restored afterward so a DPB image stays usable as a reference; the + /// destination is left in `TRANSFER_DST_OPTIMAL` (reported as the pooled + /// frame's layout, which `download` then transitions from). + pub fn copy_frame_to_image(&self, frame: &DecodedFrame, dst_image: vk::Image) -> Result<()> { + let base_layer = self.frame_array_layer(frame); + let device = self.context.device().clone(); + let aspects = vk::ImageAspectFlags::PLANE_0 | vk::ImageAspectFlags::PLANE_1; + let range = |image: vk::Image, layer: u32| vk::ImageMemoryBarrier2 { + image, + ..vk::ImageMemoryBarrier2::default() + .subresource_range(vk::ImageSubresourceRange { + aspect_mask: aspects, + base_mip_level: 0, + level_count: 1, + base_array_layer: layer, + layer_count: 1, + }) + .src_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + .dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED) + }; + + unsafe { + device + .reset_command_buffer( + self.transfer_command_buffer, + vk::CommandBufferResetFlags::empty(), + ) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + device + .begin_command_buffer( + self.transfer_command_buffer, + &vk::CommandBufferBeginInfo::default() + .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), + ) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + } + + let to_transfer = [ + vk::ImageMemoryBarrier2 { + src_stage_mask: vk::PipelineStageFlags2::NONE, + src_access_mask: vk::AccessFlags2::NONE, + dst_stage_mask: vk::PipelineStageFlags2::COPY, + dst_access_mask: vk::AccessFlags2::TRANSFER_READ, + old_layout: frame.layout, + new_layout: vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + ..range(frame.image, base_layer) + }, + vk::ImageMemoryBarrier2 { + src_stage_mask: vk::PipelineStageFlags2::NONE, + src_access_mask: vk::AccessFlags2::NONE, + dst_stage_mask: vk::PipelineStageFlags2::COPY, + dst_access_mask: vk::AccessFlags2::TRANSFER_WRITE, + old_layout: vk::ImageLayout::UNDEFINED, + new_layout: vk::ImageLayout::TRANSFER_DST_OPTIMAL, + ..range(dst_image, 0) + }, + ]; + let dep = vk::DependencyInfo::default().image_memory_barriers(&to_transfer); + unsafe { device.cmd_pipeline_barrier2(self.transfer_command_buffer, &dep) }; + + let plane = |aspect: vk::ImageAspectFlags| vk::ImageSubresourceLayers { + aspect_mask: aspect, + mip_level: 0, + base_array_layer: 0, + layer_count: 1, + }; + // Plane-1 (chroma) extents are in that plane's own coordinate space, so + // they are divided by the subsampling factor (2 for 4:2:0, 1 for 4:4:4). + let chroma_div = match self.session.as_ref().map(|s| s.pixel_format) { + Some(PixelFormat::Yuv444) => 1, + _ => 2, + }; + let regions = [ + vk::ImageCopy2::default() + .src_subresource(vk::ImageSubresourceLayers { + base_array_layer: base_layer, + ..plane(vk::ImageAspectFlags::PLANE_0) + }) + .dst_subresource(plane(vk::ImageAspectFlags::PLANE_0)) + .extent(vk::Extent3D { + width: frame.coded_width, + height: frame.coded_height, + depth: 1, + }), + vk::ImageCopy2::default() + .src_subresource(vk::ImageSubresourceLayers { + base_array_layer: base_layer, + ..plane(vk::ImageAspectFlags::PLANE_1) + }) + .dst_subresource(plane(vk::ImageAspectFlags::PLANE_1)) + .extent(vk::Extent3D { + width: frame.coded_width / chroma_div, + height: frame.coded_height / chroma_div, + depth: 1, + }), + ]; + let copy = vk::CopyImageInfo2::default() + .src_image(frame.image) + .src_image_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .dst_image(dst_image) + .dst_image_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .regions(®ions); + unsafe { device.cmd_copy_image2(self.transfer_command_buffer, ©) }; + + // Restore the source layout so a DPB image stays a valid reference. + let restore = [vk::ImageMemoryBarrier2 { + src_stage_mask: vk::PipelineStageFlags2::COPY, + src_access_mask: vk::AccessFlags2::TRANSFER_READ, + dst_stage_mask: vk::PipelineStageFlags2::NONE, + dst_access_mask: vk::AccessFlags2::NONE, + old_layout: vk::ImageLayout::TRANSFER_SRC_OPTIMAL, + new_layout: frame.layout, + ..range(frame.image, base_layer) + }]; + let dep = vk::DependencyInfo::default().image_memory_barriers(&restore); + unsafe { device.cmd_pipeline_barrier2(self.transfer_command_buffer, &dep) }; + + unsafe { + device + .end_command_buffer(self.transfer_command_buffer) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + let command_buffers = [self.transfer_command_buffer]; + let submit = vk::SubmitInfo::default().command_buffers(&command_buffers); + device + .reset_fences(&[self.transfer_fence]) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + device + .queue_submit(self.transfer_queue, &[submit], self.transfer_fence) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + device + .wait_for_fences(&[self.transfer_fence], true, u64::MAX) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + } + Ok(()) + } + + /// Copy a decoded picture's two planes into two single-plane caller images: + /// luma into `y_image` (an R8/R16 image) and interleaved chroma into + /// `uv_image` (an R8G8/R16G16 image), each in the plane's own resolution. + /// + /// This suits consumers that sample Y and UV as separate textures (the + /// common YUV→RGB shader layout) without a sampler-YCbCr conversion. Like + /// [`Self::copy_frame_to_image`] it runs on the transfer queue, restores the + /// source layout, and leaves both destinations in `TRANSFER_DST_OPTIMAL`. + /// + /// `y_image` and `uv_image` must live on this context's device and be sized + /// to the frame's coded dimensions (chroma at half size for 4:2:0). + pub fn copy_frame_to_planes( + &self, + frame: &DecodedFrame, + y_image: vk::Image, + uv_image: vk::Image, + ) -> Result<()> { + let base_layer = self.frame_array_layer(frame); + let device = self.context.device().clone(); + let chroma_div = match self.session.as_ref().map(|s| s.pixel_format) { + Some(PixelFormat::Yuv444) => 1, + _ => 2, + }; + + let color = vk::ImageSubresourceRange { + aspect_mask: vk::ImageAspectFlags::COLOR, + base_mip_level: 0, + level_count: 1, + base_array_layer: 0, + layer_count: 1, + }; + let src_planes = vk::ImageSubresourceRange { + aspect_mask: vk::ImageAspectFlags::PLANE_0 | vk::ImageAspectFlags::PLANE_1, + base_mip_level: 0, + level_count: 1, + base_array_layer: base_layer, + layer_count: 1, + }; + + unsafe { + device + .reset_command_buffer( + self.transfer_command_buffer, + vk::CommandBufferResetFlags::empty(), + ) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + device + .begin_command_buffer( + self.transfer_command_buffer, + &vk::CommandBufferBeginInfo::default() + .flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT), + ) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + } + + let qfi = vk::QUEUE_FAMILY_IGNORED; + let to_transfer = [ + vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::NONE) + .src_access_mask(vk::AccessFlags2::NONE) + .dst_stage_mask(vk::PipelineStageFlags2::COPY) + .dst_access_mask(vk::AccessFlags2::TRANSFER_READ) + .old_layout(frame.layout) + .new_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .src_queue_family_index(qfi) + .dst_queue_family_index(qfi) + .image(frame.image) + .subresource_range(src_planes), + vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::NONE) + .src_access_mask(vk::AccessFlags2::NONE) + .dst_stage_mask(vk::PipelineStageFlags2::COPY) + .dst_access_mask(vk::AccessFlags2::TRANSFER_WRITE) + .old_layout(vk::ImageLayout::UNDEFINED) + .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .src_queue_family_index(qfi) + .dst_queue_family_index(qfi) + .image(y_image) + .subresource_range(color), + vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::NONE) + .src_access_mask(vk::AccessFlags2::NONE) + .dst_stage_mask(vk::PipelineStageFlags2::COPY) + .dst_access_mask(vk::AccessFlags2::TRANSFER_WRITE) + .old_layout(vk::ImageLayout::UNDEFINED) + .new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .src_queue_family_index(qfi) + .dst_queue_family_index(qfi) + .image(uv_image) + .subresource_range(color), + ]; + let dep = vk::DependencyInfo::default().image_memory_barriers(&to_transfer); + unsafe { device.cmd_pipeline_barrier2(self.transfer_command_buffer, &dep) }; + + let color_layers = |aspect: vk::ImageAspectFlags, layer: u32| vk::ImageSubresourceLayers { + aspect_mask: aspect, + mip_level: 0, + base_array_layer: layer, + layer_count: 1, + }; + // Luma: source plane 0 -> y_image, full resolution. + let y_region = vk::ImageCopy2::default() + .src_subresource(color_layers(vk::ImageAspectFlags::PLANE_0, base_layer)) + .dst_subresource(color_layers(vk::ImageAspectFlags::COLOR, 0)) + .extent(vk::Extent3D { + width: frame.coded_width, + height: frame.coded_height, + depth: 1, + }); + let y_copy = vk::CopyImageInfo2::default() + .src_image(frame.image) + .src_image_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .dst_image(y_image) + .dst_image_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .regions(std::slice::from_ref(&y_region)); + // Chroma: source plane 1 -> uv_image, at chroma resolution. + let uv_region = vk::ImageCopy2::default() + .src_subresource(color_layers(vk::ImageAspectFlags::PLANE_1, base_layer)) + .dst_subresource(color_layers(vk::ImageAspectFlags::COLOR, 0)) + .extent(vk::Extent3D { + width: frame.coded_width / chroma_div, + height: frame.coded_height / chroma_div, + depth: 1, + }); + let uv_copy = vk::CopyImageInfo2::default() + .src_image(frame.image) + .src_image_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .dst_image(uv_image) + .dst_image_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL) + .regions(std::slice::from_ref(&uv_region)); + unsafe { + device.cmd_copy_image2(self.transfer_command_buffer, &y_copy); + device.cmd_copy_image2(self.transfer_command_buffer, &uv_copy); + } + + let restore = [vk::ImageMemoryBarrier2::default() + .src_stage_mask(vk::PipelineStageFlags2::COPY) + .src_access_mask(vk::AccessFlags2::TRANSFER_READ) + .dst_stage_mask(vk::PipelineStageFlags2::NONE) + .dst_access_mask(vk::AccessFlags2::NONE) + .old_layout(vk::ImageLayout::TRANSFER_SRC_OPTIMAL) + .new_layout(frame.layout) + .src_queue_family_index(qfi) + .dst_queue_family_index(qfi) + .image(frame.image) + .subresource_range(src_planes)]; + let dep = vk::DependencyInfo::default().image_memory_barriers(&restore); + unsafe { device.cmd_pipeline_barrier2(self.transfer_command_buffer, &dep) }; + + unsafe { + device + .end_command_buffer(self.transfer_command_buffer) + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?; + let command_buffers = [self.transfer_command_buffer]; + let submit = vk::SubmitInfo::default().command_buffers(&command_buffers); + device + .reset_fences(&[self.transfer_fence]) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + device + .queue_submit(self.transfer_queue, &[submit], self.transfer_fence) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + device + .wait_for_fences(&[self.transfer_fence], true, u64::MAX) + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + } + Ok(()) + } +} + +impl Drop for DecoderCommon { + fn drop(&mut self) { + self.destroy_session(); + unsafe { + self.context.device().device_wait_idle().ok(); + if let Some((buffer, memory, _)) = self.readback.take() { + self.context.device().destroy_buffer(buffer, None); + self.context.device().free_memory(memory, None); + } + if self.bitstream_buffer != vk::Buffer::null() { + self.context.device().unmap_memory(self.bitstream_memory); + self.context + .device() + .destroy_buffer(self.bitstream_buffer, None); + self.context + .device() + .free_memory(self.bitstream_memory, None); + } + self.context.device().destroy_fence(self.fence, None); + self.context + .device() + .destroy_command_pool(self.command_pool, None); + self.context + .device() + .destroy_fence(self.transfer_fence, None); + self.context + .device() + .destroy_command_pool(self.transfer_pool, None); + } + } +} + +/// State of one image in the reorder buffer's pool. +#[derive(Clone, Copy, PartialEq, Eq)] +enum PoolState { + /// Reusable. + Free, + /// Holds a decoded picture awaiting its turn in display order. + Buffered, + /// Returned to the caller; kept alive until the next `decode`/`flush`. + HandedOut, +} + +/// One pooled image, sized to the picture it currently holds. +/// +/// No image view: the pool image is only ever a copy target and a `download` +/// source, neither of which needs one, and a valid multi-planar view would +/// require video-decode usage that some drivers do not allow here. +struct PoolImage { + image: vk::Image, + memory: vk::DeviceMemory, + coded_width: u32, + coded_height: u32, + format: vk::Format, + state: PoolState, +} + +/// A buffered picture, referencing its pool image by index. +struct ReorderEntry { + pool_index: usize, + poc: i32, + pts: u64, + is_idr: bool, + width: u32, + height: u32, + coded_width: u32, + coded_height: u32, +} + +/// Reorders decoded pictures from decode order into display (POC) order. +/// +/// Decoded pictures are returned in the order the hardware produces them, which +/// for streams with B-frames is not display order. This buffer copies each +/// decoded picture into a pool image — so it survives while later pictures are +/// decoded ahead of it — and emits them in POC order. +/// +/// Emission follows the DPB bumping model: a picture is held until at most +/// `max_num_reorder_frames` pictures precede it in the buffer, an IDR drains the +/// previous coded video sequence (POC restarts at each IDR), and [`Self::flush`] +/// drains the rest at end of stream. +/// +/// When disabled (decode-order mode) it is a pass-through: no copy, no latency, +/// and the returned frame points straight at the decoder's DPB image. +pub(crate) struct ReorderBuffer { + enabled: bool, + pool: Vec, + buffered: Vec, +} + +impl ReorderBuffer { + pub fn new(enabled: bool) -> Self { + Self { + enabled, + pool: Vec::new(), + buffered: Vec::new(), + } + } + + /// Reclaim the images returned by the previous call. Must run before a + /// `decode`/`flush` produces new frames: it is what makes the "valid until + /// the next decode/flush" contract hold. + fn begin_batch(&mut self) { + for img in &mut self.pool { + if img.state == PoolState::HandedOut { + img.state = PoolState::Free; + } + } + } + + /// Add a freshly decoded picture and return whatever is now ready to output. + /// + /// `reorder_depth` is the stream's `max_num_reorder_frames`. + pub fn push( + &mut self, + common: &DecoderCommon, + frame: &DecodedFrame, + reorder_depth: usize, + ) -> Result> { + if !self.enabled { + return Ok(vec![frame.clone()]); + } + self.begin_batch(); + + let mut out = Vec::new(); + // POC restarts at an IDR, so the previous sequence must be fully drained + // before this picture (which belongs to the new one) is buffered. + if frame.is_idr { + out.extend(self.drain_all()); + } + + let pool_index = self.acquire_slot(common, frame)?; + common.copy_frame_to_image(frame, self.pool[pool_index].image)?; + self.pool[pool_index].state = PoolState::Buffered; + self.buffered.push(ReorderEntry { + pool_index, + poc: frame.poc, + pts: frame.pts, + is_idr: frame.is_idr, + width: frame.width, + height: frame.height, + coded_width: frame.coded_width, + coded_height: frame.coded_height, + }); + + while self.buffered.len() > reorder_depth { + out.push(self.pop_min_poc()); + } + Ok(out) + } + + /// Emit every buffered picture in display order. Call at end of stream. + pub fn flush(&mut self) -> Vec { + if !self.enabled { + return Vec::new(); + } + self.begin_batch(); + self.drain_all() + } + + /// Drain the whole buffer in ascending POC order. + fn drain_all(&mut self) -> Vec { + let mut out = Vec::with_capacity(self.buffered.len()); + while !self.buffered.is_empty() { + out.push(self.pop_min_poc()); + } + out + } + + /// Remove and return the buffered picture with the smallest POC. + fn pop_min_poc(&mut self) -> DecodedFrame { + let i = self + .buffered + .iter() + .enumerate() + .min_by_key(|(_, e)| e.poc) + .map(|(i, _)| i) + .expect("buffer is non-empty"); + let entry = self.buffered.remove(i); + let img = &mut self.pool[entry.pool_index]; + img.state = PoolState::HandedOut; + DecodedFrame { + image: img.image, + // Pool images carry no view (see PoolImage); a caller needing one + // for GPU work creates it over `image` with the usage it wants. + image_view: vk::ImageView::null(), + // copy_frame_to_image leaves the pool image in TRANSFER_DST layout. + layout: vk::ImageLayout::TRANSFER_DST_OPTIMAL, + width: entry.width, + height: entry.height, + coded_width: entry.coded_width, + coded_height: entry.coded_height, + pts: entry.pts, + poc: entry.poc, + is_idr: entry.is_idr, + } + } + + /// A free pool image matching the picture, creating or resizing one as + /// needed. Resolution changes are handled by recreating a mismatched slot. + fn acquire_slot(&mut self, common: &DecoderCommon, frame: &DecodedFrame) -> Result { + let format = common + .session + .as_ref() + .map(|s| s.picture_format) + .expect("session active while decoding"); + + let matching = self.pool.iter().position(|p| { + p.state == PoolState::Free + && p.coded_width == frame.coded_width + && p.coded_height == frame.coded_height + && p.format == format + }); + if let Some(i) = matching { + return Ok(i); + } + + let (image, memory) = create_pool_image( + &common.context, + frame.coded_width, + frame.coded_height, + format, + )?; + let slot = PoolImage { + image, + memory, + coded_width: frame.coded_width, + coded_height: frame.coded_height, + format, + state: PoolState::Free, + }; + + // Reuse a free-but-mismatched slot if one exists, else grow the pool. + if let Some(i) = self.pool.iter().position(|p| p.state == PoolState::Free) { + self.destroy_pool_image(common, i); + self.pool[i] = slot; + Ok(i) + } else { + self.pool.push(slot); + Ok(self.pool.len() - 1) + } + } + + fn destroy_pool_image(&mut self, common: &DecoderCommon, i: usize) { + let p = &self.pool[i]; + if p.image == vk::Image::null() { + return; + } + unsafe { + common.context.device().device_wait_idle().ok(); + common.context.device().destroy_image(p.image, None); + common.context.device().free_memory(p.memory, None); + } + } + + /// Free every pool image. The caller must be done with handed-out frames. + pub fn destroy(&mut self, common: &DecoderCommon) { + for i in 0..self.pool.len() { + self.destroy_pool_image(common, i); + self.pool[i].image = vk::Image::null(); + } + self.pool.clear(); + self.buffered.clear(); + } +} + +/// A plain device-local image for the reorder pool: a copy target and readback +/// source, with no view and no video profile (so it works on every driver). +fn create_pool_image( + context: &VideoContext, + width: u32, + height: u32, + format: vk::Format, +) -> Result<(vk::Image, vk::DeviceMemory)> { + let create_info = vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(format) + .extent(vk::Extent3D { + width, + height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(vk::ImageUsageFlags::TRANSFER_DST | vk::ImageUsageFlags::TRANSFER_SRC) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .initial_layout(vk::ImageLayout::UNDEFINED); + let image = unsafe { context.device().create_image(&create_info, None) } + .map_err(|e| PixelForgeError::ResourceCreation(format!("reorder pool image: {}", e)))?; + + let reqs = unsafe { context.device().get_image_memory_requirements(image) }; + let memory_type_index = find_memory_type( + context.memory_properties(), + reqs.memory_type_bits, + vk::MemoryPropertyFlags::DEVICE_LOCAL, + ) + .ok_or_else(|| { + PixelForgeError::MemoryAllocation("No device-local memory for reorder pool".to_string()) + })?; + let alloc_info = vk::MemoryAllocateInfo::default() + .allocation_size(reqs.size) + .memory_type_index(memory_type_index); + let memory = unsafe { context.device().allocate_memory(&alloc_info, None) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + unsafe { context.device().bind_image_memory(image, memory, 0) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + + Ok((image, memory)) +} + +/// A command pool, one primary command buffer, and a fence for `family`. +fn create_command_resources( + context: &VideoContext, + family: u32, + label: &str, +) -> Result<(vk::CommandPool, vk::CommandBuffer, vk::Fence)> { + let pool_info = vk::CommandPoolCreateInfo::default() + .queue_family_index(family) + .flags(vk::CommandPoolCreateFlags::RESET_COMMAND_BUFFER); + let pool = unsafe { context.device().create_command_pool(&pool_info, None) } + .map_err(|e| PixelForgeError::ResourceCreation(format!("{} command pool: {}", label, e)))?; + + let alloc_info = vk::CommandBufferAllocateInfo::default() + .command_pool(pool) + .level(vk::CommandBufferLevel::PRIMARY) + .command_buffer_count(1); + let buffer = unsafe { context.device().allocate_command_buffers(&alloc_info) } + .map_err(|e| PixelForgeError::CommandBuffer(e.to_string()))?[0]; + + let fence = unsafe { + context + .device() + .create_fence(&vk::FenceCreateInfo::default(), None) + } + .map_err(|e| PixelForgeError::Synchronization(e.to_string()))?; + + Ok((pool, buffer, fence)) +} + +/// Byte layout of a decoded picture in the readback buffer, and of the visible +/// region the host actually copies out. +/// +/// The decoded image is `coded_width` x `coded_height` (macroblock-aligned), +/// but only `width` x `height` is displayed, so the host reads a cropped +/// sub-rectangle out of a larger buffer. +#[derive(Debug, Clone, Copy)] +pub(crate) struct PlaneGeometry { + /// 1 for 4:4:4, 2 for 4:2:0. + pub chroma_div: usize, + pub y_stride: usize, + pub y_size: usize, + pub uv_stride: usize, + pub total: usize, + pub visible_y_stride: usize, + pub visible_uv_stride: usize, + pub visible_y_rows: usize, + pub visible_uv_rows: usize, +} + +impl PlaneGeometry { + pub fn new( + coded_width: u32, + coded_height: u32, + width: u32, + height: u32, + bit_depth: BitDepth, + pixel_format: PixelFormat, + ) -> Self { + let bytes_per_sample = match bit_depth { + BitDepth::Eight => 1, + BitDepth::Ten => 2, + }; + let chroma_div = match pixel_format { + PixelFormat::Yuv444 => 1, + _ => 2, + }; + let y_stride = coded_width as usize * bytes_per_sample; + let y_size = y_stride * coded_height as usize; + let uv_stride = (coded_width as usize / chroma_div) * 2 * bytes_per_sample; + let uv_size = uv_stride * (coded_height as usize / chroma_div); + Self { + chroma_div, + y_stride, + y_size, + uv_stride, + total: y_size + uv_size, + visible_y_stride: width as usize * bytes_per_sample, + visible_uv_stride: (width as usize / chroma_div) * 2 * bytes_per_sample, + visible_y_rows: height as usize, + visible_uv_rows: height as usize / chroma_div, + } + } + + /// Byte offset one past the last byte the host reads. Must not exceed + /// [`Self::total`], or `download` would read out of bounds. + pub fn max_read_offset(&self) -> usize { + let last_y = if self.visible_y_rows == 0 { + 0 + } else { + (self.visible_y_rows - 1) * self.y_stride + self.visible_y_stride + }; + let last_uv = if self.visible_uv_rows == 0 { + 0 + } else { + self.y_size + (self.visible_uv_rows - 1) * self.uv_stride + self.visible_uv_stride + }; + last_y.max(last_uv) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn plane_geometry_reads_stay_in_bounds() { + // (coded_width, coded_height, width, height) + let cases = [ + (1920, 1088, 1920, 1080), + (320, 240, 320, 240), + (1280, 720, 1280, 720), + (3840, 2160, 3840, 2160), + (640, 480, 636, 476), + ]; + for (cw, ch, w, h) in cases { + for bd in [BitDepth::Eight, BitDepth::Ten] { + for pf in [PixelFormat::Yuv420, PixelFormat::Yuv444] { + let g = PlaneGeometry::new(cw, ch, w, h, bd, pf); + assert!( + g.max_read_offset() <= g.total, + "{cw}x{ch} -> {w}x{h} {bd:?} {pf:?}: reads {} bytes but buffer is {}", + g.max_read_offset(), + g.total + ); + } + } + } + } + + #[test] + fn plane_geometry_nv12_1080p() { + let g = PlaneGeometry::new(1920, 1088, 1920, 1080, BitDepth::Eight, PixelFormat::Yuv420); + assert_eq!(g.y_stride, 1920); + assert_eq!(g.y_size, 1920 * 1088); + assert_eq!(g.uv_stride, 1920); + assert_eq!(g.total, 1920 * 1088 + 1920 * 544); + assert_eq!(g.visible_y_rows, 1080); + assert_eq!(g.visible_uv_rows, 540); + } + + #[test] + fn plane_geometry_p010_is_two_bytes_per_sample() { + let g = PlaneGeometry::new(1920, 1088, 1920, 1080, BitDepth::Ten, PixelFormat::Yuv420); + assert_eq!(g.y_stride, 1920 * 2); + assert_eq!(g.visible_y_stride, 1920 * 2); + } + + #[test] + fn plane_geometry_yuv444_chroma_is_full_size() { + let g = PlaneGeometry::new(320, 240, 320, 240, BitDepth::Eight, PixelFormat::Yuv444); + assert_eq!(g.chroma_div, 1); + assert_eq!(g.uv_stride, 320 * 2); + assert_eq!(g.visible_uv_rows, 240); + } +} diff --git a/src/decoder/h264/dpb.rs b/src/decoder/h264/dpb.rs new file mode 100644 index 0000000..a18a27b --- /dev/null +++ b/src/decoder/h264/dpb.rs @@ -0,0 +1,867 @@ +//! Decode-side H.264 picture order count and reference picture management. +//! +//! The encoder's DPB (`crate::encoder::dpb`) decides *which* pictures to +//! reference; a decoder instead reconstructs the state the encoder implied from +//! the bitstream syntax. That makes this a separate, much smaller piece of +//! bookkeeping: compute POC, hand the driver the current reference set, and +//! retire pictures. +//! +//! Retirement follows whichever process the bitstream asks for: the sliding +//! window (clause 8.2.5.3), or the explicit MMCO commands in +//! `dec_ref_pic_marking()` (clause 8.2.5.4). Both are required -- x264 emits +//! MMCO whenever B-pyramid is enabled, and applying the sliding window in its +//! place silently decodes the wrong pictures. + +use crate::decoder::h264::parser::{Mmco, NalType, RefPicMarking, SliceHeader, Sps}; +use crate::error::{PixelForgeError, Result}; + +/// Maximum DPB slots addressable by the H.264 spec (16 frames + 1 current). +pub(crate) const MAX_DPB_SLOTS: usize = 17; + +/// A picture currently held in the DPB as a reference. +#[derive(Debug, Clone, Copy)] +pub(crate) struct RefPicture { + /// DPB slot index this picture lives in. + pub slot: u8, + /// `frame_num` from the slice header. + pub frame_num: u16, + /// Wrapped frame number used for sliding-window ordering (`FrameNumWrap`). + /// + /// For frame pictures this is also `PicNum`, which is what MMCO operands + /// are expressed in, so it must be refreshed against the current picture + /// before any marking is applied (clause 8.2.4.1). + pub frame_num_wrap: i32, + /// Picture order count (top and bottom; identical for frame pictures). + pub poc: [i32; 2], + /// Whether this is a long-term reference. + pub long_term: bool, + /// `LongTermFrameIdx`, meaningful only when `long_term` is set. For frame + /// pictures this doubles as `LongTermPicNum`. + pub long_term_frame_idx: u32, +} + +/// Per-picture POC and reference state derived from the bitstream. +#[derive(Debug, Clone)] +pub(crate) struct PictureState { + /// Picture order count of the current picture. + pub poc: i32, + /// `frame_num` of the current picture. + pub frame_num: u16, + /// Whether the current picture is an IDR. + pub is_idr: bool, + /// Whether the current picture is used for reference (`nal_ref_idc != 0`). + pub is_reference: bool, + /// Whether the current picture contains only intra slices. + pub is_intra: bool, + /// `idr_pic_id` (only meaningful for IDR pictures). + pub idr_pic_id: u16, + /// `dec_ref_pic_marking()` from the slice header, driving how this picture + /// retires earlier references once decoding completes. + /// + /// A picture carrying MMCO 5 already has `poc` and `frame_num` rebased to + /// zero by [`DecodeDpb::begin_picture`]. + pub marking: RefPicMarking, +} + +/// Decode-side DPB: POC state machine plus the reference picture set. +pub(crate) struct DecodeDpb { + /// Reference pictures, most recently decoded last. + refs: Vec, + /// Which slots are occupied (by a reference or the in-flight picture). + slot_used: [bool; MAX_DPB_SLOTS], + /// Number of slots the session was created with. + slot_count: usize, + /// `MaxLongTermFrameIdx`, or `None` for "no long-term frame indices" + /// (the initial state and the state after an IDR without + /// `long_term_reference_flag`). Set by MMCO 4. + max_long_term_frame_idx: Option, + + // --- POC type 0 state (8.2.1.1) --- + prev_poc_msb: i32, + prev_poc_lsb: i32, + // --- POC type 1/2 state (8.2.1.2, 8.2.1.3) --- + prev_frame_num_offset: i32, + prev_frame_num: u32, +} + +impl DecodeDpb { + pub fn new(slot_count: usize) -> Self { + Self { + refs: Vec::new(), + slot_used: [false; MAX_DPB_SLOTS], + slot_count: slot_count.min(MAX_DPB_SLOTS), + max_long_term_frame_idx: None, + prev_poc_msb: 0, + prev_poc_lsb: 0, + prev_frame_num_offset: 0, + prev_frame_num: 0, + } + } + + /// Current reference pictures, in the order they were decoded. + pub fn references(&self) -> &[RefPicture] { + &self.refs + } + + /// Compute the POC and reference state for the picture about to be decoded. + /// + /// Implements the frame-picture subset of clause 8.2.1. Must be called + /// exactly once per picture, before [`Self::allocate_slot`]. + pub fn begin_picture( + &mut self, + nal_type: NalType, + ref_idc: u8, + header: &SliceHeader, + sps: &Sps, + is_intra: bool, + ) -> Result { + if header.field_pic_flag { + return Err(PixelForgeError::InvalidInput( + "H.264 decode: field/interlaced pictures are not supported".to_string(), + )); + } + + let is_idr = nal_type == NalType::IdrSlice; + let is_reference = ref_idc != 0; + + if is_idr { + // An IDR empties the DPB and resets all POC state. + self.refs.clear(); + self.slot_used = [false; MAX_DPB_SLOTS]; + self.prev_poc_msb = 0; + self.prev_poc_lsb = 0; + self.prev_frame_num_offset = 0; + self.prev_frame_num = 0; + } + + let mut poc = match sps.pic_order_cnt_type { + 0 => self.compute_poc_type0(header, sps, is_idr, is_reference), + 1 => self.compute_poc_type1(header, sps, is_idr, is_reference), + 2 => self.compute_poc_type2(header, sps, is_idr, is_reference), + other => { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: unsupported pic_order_cnt_type {}", + other + ))); + } + }; + + self.prev_frame_num = header.frame_num; + + // MMCO 5 makes the current picture behave like the start of a new + // sequence: it is inferred to have frame_num 0, its POC is rebased so + // that PicOrderCnt(CurrPic) becomes 0, and the POC predictors reset + // (clauses 8.2.1 and 8.2.5.4). + let mmco5 = is_reference && header.marking.ops.contains(&Mmco::ForgetAll); + let mut frame_num = header.frame_num as u16; + if mmco5 { + poc = 0; + frame_num = 0; + self.prev_poc_msb = 0; + self.prev_poc_lsb = 0; + self.prev_frame_num_offset = 0; + self.prev_frame_num = 0; + } + + Ok(PictureState { + poc, + frame_num, + is_idr, + is_reference, + is_intra, + idr_pic_id: header.idr_pic_id, + marking: header.marking.clone(), + }) + } + + /// POC type 0 (8.2.1.1): explicit LSB in the slice header, MSB tracked here. + fn compute_poc_type0( + &mut self, + header: &SliceHeader, + sps: &Sps, + is_idr: bool, + is_reference: bool, + ) -> i32 { + let max_lsb = sps.max_pic_order_cnt_lsb() as i32; + let lsb = header.pic_order_cnt_lsb as i32; + + let poc_msb = if is_idr { + 0 + } else if lsb < self.prev_poc_lsb && (self.prev_poc_lsb - lsb) >= max_lsb / 2 { + self.prev_poc_msb + max_lsb + } else if lsb > self.prev_poc_lsb && (lsb - self.prev_poc_lsb) > max_lsb / 2 { + self.prev_poc_msb - max_lsb + } else { + self.prev_poc_msb + }; + + // Only reference pictures update the prev* state. + if is_reference { + self.prev_poc_msb = poc_msb; + self.prev_poc_lsb = lsb; + } + + poc_msb + lsb + } + + /// Frame number offset shared by POC types 1 and 2 (handles frame_num wrap). + fn frame_num_offset(&mut self, header: &SliceHeader, sps: &Sps, is_idr: bool) -> i32 { + let max_frame_num = sps.max_frame_num() as i32; + let offset = if is_idr { + 0 + } else if (self.prev_frame_num as i32) > (header.frame_num as i32) { + self.prev_frame_num_offset + max_frame_num + } else { + self.prev_frame_num_offset + }; + self.prev_frame_num_offset = offset; + offset + } + + /// POC type 1 (8.2.1.2): POC derived from a repeating cycle in the SPS. + fn compute_poc_type1( + &mut self, + header: &SliceHeader, + sps: &Sps, + is_idr: bool, + is_reference: bool, + ) -> i32 { + let frame_num_offset = self.frame_num_offset(header, sps, is_idr); + let num_in_cycle = sps.offsets_for_ref_frame.len() as i32; + + let abs_frame_num = if num_in_cycle != 0 { + let n = frame_num_offset + header.frame_num as i32; + if !is_reference && n > 0 { n - 1 } else { n } + } else { + 0 + }; + + let mut expected_poc = 0i32; + if abs_frame_num > 0 { + let cycle: i32 = sps.offsets_for_ref_frame.iter().sum(); + let poc_cycle_cnt = (abs_frame_num - 1) / num_in_cycle; + let frame_num_in_cycle = (abs_frame_num - 1) % num_in_cycle; + expected_poc = poc_cycle_cnt * cycle; + for i in 0..=frame_num_in_cycle { + expected_poc += sps.offsets_for_ref_frame[i as usize]; + } + } + if !is_reference { + expected_poc += sps.offset_for_non_ref_pic; + } + + expected_poc + header.delta_pic_order_cnt[0] + } + + /// POC type 2 (8.2.1.3): POC is a direct function of frame_num (decode order). + fn compute_poc_type2( + &mut self, + header: &SliceHeader, + sps: &Sps, + is_idr: bool, + is_reference: bool, + ) -> i32 { + let frame_num_offset = self.frame_num_offset(header, sps, is_idr); + if is_idr { + return 0; + } + let temp = frame_num_offset + header.frame_num as i32; + if is_reference { 2 * temp } else { 2 * temp - 1 } + } + + /// Reserve a DPB slot for the current picture. + /// + /// The current picture always needs a slot, even when it is not a + /// reference, because Vulkan decodes into a DPB resource. Returns an error + /// only if the DPB is inconsistent (more references live than slots). + pub fn allocate_slot(&mut self) -> Result { + for (slot, used) in self.slot_used.iter_mut().take(self.slot_count).enumerate() { + if !*used { + *used = true; + return Ok(slot as u8); + } + } + Err(PixelForgeError::InvalidInput( + "H.264 decode: no free DPB slot (stream exceeds negotiated DPB size)".to_string(), + )) + } + + /// Release a slot that was allocated for a non-reference picture. + pub fn release_slot(&mut self, slot: u8) { + self.slot_used[slot as usize] = false; + } + + /// Retire references and insert the just-decoded picture (clause 8.2.5). + /// + /// Dispatches to the explicit marking process when the slice header carries + /// MMCO commands, and to the sliding window otherwise. If + /// `state.is_reference` is false the picture's slot is released and the + /// reference set is untouched. + pub fn end_picture(&mut self, slot: u8, state: &PictureState, sps: &Sps) { + if !state.is_reference { + self.release_slot(slot); + return; + } + + // PicNum is derived relative to the current picture and MMCO operands + // are expressed in it, so this must happen before any marking. + self.refresh_pic_nums(state.frame_num as i32, sps); + + if state.is_idr { + // begin_picture already emptied the DPB. + self.refs.clear(); + let long_term = state.marking.long_term_reference_flag; + self.max_long_term_frame_idx = long_term.then_some(0); + self.push_current(slot, state, long_term, 0); + return; + } + + if state.marking.adaptive { + self.apply_mmco(slot, state); + } else { + self.sliding_window(sps); + self.push_current(slot, state, false, 0); + } + } + + /// Recompute `FrameNumWrap` (== `PicNum` for frames) for every retained + /// reference, relative to the current picture (clause 8.2.4.1). + fn refresh_pic_nums(&mut self, current_frame_num: i32, sps: &Sps) { + let max_frame_num = sps.max_frame_num() as i32; + for r in &mut self.refs { + r.frame_num_wrap = if (r.frame_num as i32) > current_frame_num { + r.frame_num as i32 - max_frame_num + } else { + r.frame_num as i32 + }; + } + } + + /// Sliding-window marking (clause 8.2.5.3): evict the short-term reference + /// with the smallest `FrameNumWrap` until there is room for the current + /// picture. + fn sliding_window(&mut self, sps: &Sps) { + let max_refs = (sps.max_num_ref_frames as usize).max(1); + while self.refs.len() >= max_refs { + let victim = self + .refs + .iter() + .enumerate() + .filter(|(_, r)| !r.long_term) + .min_by_key(|(_, r)| r.frame_num_wrap) + .map(|(i, _)| i); + match victim { + Some(i) => { + let removed = self.refs.remove(i); + self.slot_used[removed.slot as usize] = false; + } + // Only long-term refs remain; nothing to slide out. + None => break, + } + } + } + + /// Explicit marking (clause 8.2.5.4). Encoders that use B-pyramid rely on + /// this to retire a B-reference at a point the sliding window would not. + fn apply_mmco(&mut self, slot: u8, state: &PictureState) { + // CurrPicNum == frame_num for a frame picture (clause 8.2.4.1). + let curr_pic_num = state.frame_num as i32; + let mut current_long_term_idx = None; + + for op in &state.marking.ops { + match *op { + Mmco::ForgetShort { + difference_of_pic_nums_minus1, + } => { + let pic_num_x = curr_pic_num - (difference_of_pic_nums_minus1 as i32 + 1); + self.remove_refs(|r| !r.long_term && r.frame_num_wrap == pic_num_x); + } + Mmco::ForgetLong { long_term_pic_num } => { + self.remove_refs(|r| r.long_term && r.long_term_frame_idx == long_term_pic_num); + } + Mmco::ShortToLong { + difference_of_pic_nums_minus1, + long_term_frame_idx, + } => { + let pic_num_x = curr_pic_num - (difference_of_pic_nums_minus1 as i32 + 1); + // A long-term index is unique: whatever holds it is displaced. + self.remove_refs(|r| { + r.long_term && r.long_term_frame_idx == long_term_frame_idx + }); + if let Some(r) = self + .refs + .iter_mut() + .find(|r| !r.long_term && r.frame_num_wrap == pic_num_x) + { + r.long_term = true; + r.long_term_frame_idx = long_term_frame_idx; + } + } + Mmco::MaxLongTermIdx { + max_long_term_frame_idx_plus1, + } => { + // 0 means "no long-term frame indices", so every long-term + // reference goes; otherwise those above the bound go. + self.max_long_term_frame_idx = max_long_term_frame_idx_plus1.checked_sub(1); + let max = self.max_long_term_frame_idx; + self.remove_refs(|r| { + r.long_term && max.is_none_or(|m| r.long_term_frame_idx > m) + }); + } + Mmco::ForgetAll => { + for r in std::mem::take(&mut self.refs) { + self.slot_used[r.slot as usize] = false; + } + self.max_long_term_frame_idx = None; + } + Mmco::CurrentToLong { + long_term_frame_idx, + } => { + self.remove_refs(|r| { + r.long_term && r.long_term_frame_idx == long_term_frame_idx + }); + current_long_term_idx = Some(long_term_frame_idx); + } + } + } + + match current_long_term_idx { + Some(idx) => self.push_current(slot, state, true, idx), + None => self.push_current(slot, state, false, 0), + } + } + + /// Drop every reference matching `pred`, freeing its slot. + fn remove_refs(&mut self, pred: impl Fn(&RefPicture) -> bool) { + let slot_used = &mut self.slot_used; + self.refs.retain(|r| { + let drop_it = pred(r); + if drop_it { + slot_used[r.slot as usize] = false; + } + !drop_it + }); + } + + /// Insert the just-decoded picture into the reference set. + fn push_current( + &mut self, + slot: u8, + state: &PictureState, + long_term: bool, + long_term_frame_idx: u32, + ) { + self.refs.push(RefPicture { + slot, + frame_num: state.frame_num, + frame_num_wrap: state.frame_num as i32, + poc: [state.poc, state.poc], + long_term, + long_term_frame_idx, + }); + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::decoder::h264::parser::{SliceHeader, SliceType}; + + fn sps(poc_type: u8) -> Sps { + Sps { + pic_order_cnt_type: poc_type, + log2_max_frame_num_minus4: 0, // MaxFrameNum = 16 + log2_max_pic_order_cnt_lsb_minus4: 0, // MaxPocLsb = 16 + max_num_ref_frames: 2, + frame_mbs_only_flag: true, + ..Default::default() + } + } + + fn header(frame_num: u32, poc_lsb: u32) -> SliceHeader { + SliceHeader { + first_mb_in_slice: 0, + slice_type: SliceType::P, + pps_id: 0, + frame_num, + field_pic_flag: false, + idr_pic_id: 0, + pic_order_cnt_lsb: poc_lsb, + delta_pic_order_cnt: [0, 0], + marking: RefPicMarking::default(), + } + } + + /// A header carrying explicit reference marking. + fn header_with_mmco(frame_num: u32, poc_lsb: u32, ops: Vec) -> SliceHeader { + SliceHeader { + marking: RefPicMarking { + adaptive: true, + ops, + ..Default::default() + }, + ..header(frame_num, poc_lsb) + } + } + + /// Decode one reference frame picture and return its slot. + fn decode_ref(dpb: &mut DecodeDpb, sps: &Sps, header: &SliceHeader) -> u8 { + let state = dpb + .begin_picture(NalType::Slice, 2, header, sps, false) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &state, sps); + slot + } + + /// The (slot, poc, long_term) triples currently held as references. + fn ref_summary(dpb: &DecodeDpb) -> Vec<(u8, i32, bool)> { + dpb.references() + .iter() + .map(|r| (r.slot, r.poc[0], r.long_term)) + .collect() + } + + #[test] + fn test_poc_type0_wraps() { + let sps = sps(0); + let mut dpb = DecodeDpb::new(4); + // IDR at POC 0. + let s = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + assert_eq!(s.poc, 0); + // POC lsb increments by 2 per frame: 2, 4, ... 14, then wraps to 0. + for i in 1..8 { + let s = dpb + .begin_picture(NalType::Slice, 2, &header(i, i * 2), &sps, false) + .unwrap(); + assert_eq!(s.poc, (i * 2) as i32); + } + // lsb wraps 14 -> 0: MSB must advance by MaxPocLsb (16). + let s = dpb + .begin_picture(NalType::Slice, 2, &header(8, 0), &sps, false) + .unwrap(); + assert_eq!(s.poc, 16); + let s = dpb + .begin_picture(NalType::Slice, 2, &header(9, 2), &sps, false) + .unwrap(); + assert_eq!(s.poc, 18); + } + + #[test] + fn test_poc_type2_is_decode_order() { + let sps = sps(2); + let mut dpb = DecodeDpb::new(4); + let s = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + assert_eq!(s.poc, 0); + let s = dpb + .begin_picture(NalType::Slice, 2, &header(1, 0), &sps, false) + .unwrap(); + assert_eq!(s.poc, 2); + let s = dpb + .begin_picture(NalType::Slice, 2, &header(2, 0), &sps, false) + .unwrap(); + assert_eq!(s.poc, 4); + } + + #[test] + fn test_sliding_window_evicts_oldest() { + let sps = sps(0); // max_num_ref_frames = 2 + let mut dpb = DecodeDpb::new(4); + + // IDR. + let s = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &s, &sps); + assert_eq!(dpb.references().len(), 1); + + // Two more reference frames: window fills, then evicts the IDR. + for i in 1..=2u32 { + let s = dpb + .begin_picture(NalType::Slice, 2, &header(i, i * 2), &sps, false) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &s, &sps); + } + assert_eq!(dpb.references().len(), 2); + let frame_nums: Vec = dpb.references().iter().map(|r| r.frame_num).collect(); + assert_eq!(frame_nums, vec![1, 2]); // frame_num 0 (the IDR) slid out. + } + + /// MMCO 1 retires a specific short-term reference that the sliding window + /// would have kept. This is what B-pyramid streams rely on. + #[test] + fn test_mmco1_forgets_specific_short_term_ref() { + let mut sps = sps(0); + sps.max_num_ref_frames = 4; // Sliding window would not evict anything. + let mut dpb = DecodeDpb::new(6); + + let idr = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + let idr_slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(idr_slot, &idr, &sps); + let s1 = decode_ref(&mut dpb, &sps, &header(1, 2)); + decode_ref(&mut dpb, &sps, &header(2, 4)); + assert_eq!(dpb.references().len(), 3); + + // From frame_num 3, difference_of_pic_nums_minus1 = 1 targets + // PicNum 3 - (1 + 1) = 1, i.e. the frame_num 1 picture. + let marked = header_with_mmco( + 3, + 6, + vec![Mmco::ForgetShort { + difference_of_pic_nums_minus1: 1, + }], + ); + decode_ref(&mut dpb, &sps, &marked); + + let frame_nums: Vec = dpb.references().iter().map(|r| r.frame_num).collect(); + assert_eq!(frame_nums, vec![0, 2, 3], "frame_num 1 should be retired"); + // Its slot must be reusable now. + assert_eq!(dpb.allocate_slot().unwrap(), s1); + } + + /// MMCO 6 marks the current picture long-term; MMCO 2 later drops it by + /// LongTermPicNum. Long-term refs are immune to the sliding window. + #[test] + fn test_mmco6_and_mmco2_long_term_lifecycle() { + let sps = sps(0); // max_num_ref_frames = 2 + let mut dpb = DecodeDpb::new(6); + + let idr = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &idr, &sps); + + // Current picture becomes long-term index 0. + let marked = header_with_mmco( + 1, + 2, + vec![Mmco::CurrentToLong { + long_term_frame_idx: 0, + }], + ); + decode_ref(&mut dpb, &sps, &marked); + assert_eq!(ref_summary(&dpb), vec![(0, 0, false), (1, 2, true)]); + + // A long-term reference survives sliding-window pressure that would + // otherwise evict the oldest picture. + decode_ref(&mut dpb, &sps, &header(2, 4)); + decode_ref(&mut dpb, &sps, &header(3, 6)); + assert!( + dpb.references().iter().any(|r| r.long_term), + "long-term ref must not slide out" + ); + + // MMCO 2 drops it explicitly by LongTermPicNum. + let marked = header_with_mmco( + 4, + 8, + vec![Mmco::ForgetLong { + long_term_pic_num: 0, + }], + ); + decode_ref(&mut dpb, &sps, &marked); + assert!( + !dpb.references().iter().any(|r| r.long_term), + "long-term ref should be gone" + ); + } + + /// MMCO 3 converts a short-term reference into a long-term one in place. + #[test] + fn test_mmco3_converts_short_to_long_term() { + let mut sps = sps(0); + sps.max_num_ref_frames = 4; + let mut dpb = DecodeDpb::new(6); + + let idr = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &idr, &sps); + decode_ref(&mut dpb, &sps, &header(1, 2)); + + // From frame_num 2, target PicNum 2 - (0 + 1) = 1. + let marked = header_with_mmco( + 2, + 4, + vec![Mmco::ShortToLong { + difference_of_pic_nums_minus1: 0, + long_term_frame_idx: 3, + }], + ); + decode_ref(&mut dpb, &sps, &marked); + + let converted = dpb + .references() + .iter() + .find(|r| r.frame_num == 1) + .expect("frame_num 1 retained"); + assert!(converted.long_term); + assert_eq!(converted.long_term_frame_idx, 3); + } + + /// MMCO 4 bounds LongTermFrameIdx; a bound of 0 (plus1 == 0) clears them all. + #[test] + fn test_mmco4_bounds_long_term_indices() { + let mut sps = sps(0); + sps.max_num_ref_frames = 4; + let mut dpb = DecodeDpb::new(6); + + let idr = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &idr, &sps); + + let marked = header_with_mmco( + 1, + 2, + vec![Mmco::CurrentToLong { + long_term_frame_idx: 2, + }], + ); + decode_ref(&mut dpb, &sps, &marked); + assert!(dpb.references().iter().any(|r| r.long_term_frame_idx == 2)); + + // max_long_term_frame_idx_plus1 = 2 => MaxLongTermFrameIdx = 1, so + // index 2 is out of range and must go. + let marked = header_with_mmco( + 2, + 4, + vec![Mmco::MaxLongTermIdx { + max_long_term_frame_idx_plus1: 2, + }], + ); + decode_ref(&mut dpb, &sps, &marked); + assert!( + !dpb.references().iter().any(|r| r.long_term), + "long-term index above the bound must be retired" + ); + } + + /// MMCO 5 empties the DPB and rebases frame_num/POC to zero. + #[test] + fn test_mmco5_resets_dpb_and_poc() { + let sps = sps(0); + let mut dpb = DecodeDpb::new(6); + + let idr = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &idr, &sps); + decode_ref(&mut dpb, &sps, &header(1, 2)); + assert_eq!(dpb.references().len(), 2); + + let marked = header_with_mmco(2, 4, vec![Mmco::ForgetAll]); + let state = dpb + .begin_picture(NalType::Slice, 2, &marked, &sps, false) + .unwrap(); + // The picture is rebased rather than keeping poc 4 / frame_num 2. + assert_eq!(state.poc, 0); + assert_eq!(state.frame_num, 0); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &state, &sps); + + // Everything prior is gone; only the rebased picture remains. + assert_eq!(ref_summary(&dpb), vec![(slot, 0, false)]); + + // POC prediction continues from the reset state. + let next = dpb + .begin_picture(NalType::Slice, 2, &header(1, 2), &sps, false) + .unwrap(); + assert_eq!(next.poc, 2); + } + + /// Marking is skipped entirely for a picture that is not a reference. + #[test] + fn test_non_reference_picture_ignores_marking() { + let sps = sps(0); + let mut dpb = DecodeDpb::new(4); + let idr = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &idr, &sps); + + // nal_ref_idc == 0, so dec_ref_pic_marking() is not even present. + let marked = header_with_mmco(1, 2, vec![Mmco::ForgetAll]); + let state = dpb + .begin_picture(NalType::Slice, 0, &marked, &sps, false) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &state, &sps); + assert_eq!(dpb.references().len(), 1, "IDR must survive"); + } + + #[test] + fn test_non_reference_picture_releases_slot() { + let sps = sps(0); + let mut dpb = DecodeDpb::new(4); + let s = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &s, &sps); + + // nal_ref_idc == 0 => disposable picture. + let s = dpb + .begin_picture(NalType::Slice, 0, &header(1, 2), &sps, false) + .unwrap(); + assert!(!s.is_reference); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &s, &sps); + assert_eq!(dpb.references().len(), 1); + // The slot is reusable immediately. + assert_eq!(dpb.allocate_slot().unwrap(), slot); + } + + #[test] + fn test_idr_flushes_dpb() { + let sps = sps(0); + let mut dpb = DecodeDpb::new(4); + for i in 0..2u32 { + let nal = if i == 0 { + NalType::IdrSlice + } else { + NalType::Slice + }; + let s = dpb + .begin_picture(nal, 3, &header(i, i * 2), &sps, i == 0) + .unwrap(); + let slot = dpb.allocate_slot().unwrap(); + dpb.end_picture(slot, &s, &sps); + } + assert_eq!(dpb.references().len(), 2); + + let s = dpb + .begin_picture(NalType::IdrSlice, 3, &header(0, 0), &sps, true) + .unwrap(); + assert_eq!(s.poc, 0); + assert!(dpb.references().is_empty()); + // All slots freed by the IDR. + assert_eq!(dpb.allocate_slot().unwrap(), 0); + } + + #[test] + fn test_field_pictures_rejected() { + let mut sps = sps(0); + sps.frame_mbs_only_flag = false; + let mut dpb = DecodeDpb::new(4); + let mut h = header(0, 0); + h.field_pic_flag = true; + assert!( + dpb.begin_picture(NalType::IdrSlice, 3, &h, &sps, true) + .is_err() + ); + } +} diff --git a/src/decoder/h264/mod.rs b/src/decoder/h264/mod.rs new file mode 100644 index 0000000..4b58e74 --- /dev/null +++ b/src/decoder/h264/mod.rs @@ -0,0 +1,47 @@ +//! H.264 decoding on top of Vulkan Video. +//! +//! [`parser`] does the host-side syntax work; this module owns the Vulkan +//! session, the decoded picture buffer and the per-picture record/submit flow. + +pub(crate) mod parser; + +/// Parser tests against real x264 streams (host-side, no GPU). In-crate so they +/// reach parser internals directly, compiled only under `cargo test`. +#[cfg(test)] +mod parser_tests; + +mod dpb; +mod session; + +use crate::decoder::{DecodedFrame, DecodedFrameData}; +use crate::error::Result; +use ash::vk; + +pub(crate) use session::H264Decoder; + +impl crate::decoder::DecoderApi for H264Decoder { + fn decode(&mut self, data: &[u8], pts: u64) -> Result> { + H264Decoder::decode(self, data, pts) + } + + fn flush(&mut self) -> Result> { + H264Decoder::flush(self) + } + + fn download(&mut self, frame: &DecodedFrame) -> Result { + H264Decoder::download(self, frame) + } + + fn copy_frame_to_planes( + &mut self, + frame: &DecodedFrame, + y_image: vk::Image, + uv_image: vk::Image, + ) -> Result<()> { + H264Decoder::copy_frame_to_planes(self, frame, y_image, uv_image) + } + + fn picture_format(&self) -> Option { + H264Decoder::picture_format(self) + } +} diff --git a/src/decoder/h264/parser.rs b/src/decoder/h264/parser.rs new file mode 100644 index 0000000..5a259cf --- /dev/null +++ b/src/decoder/h264/parser.rs @@ -0,0 +1,858 @@ +//! Minimal H.264 Annex B parsing: NAL unit splitting plus SPS, PPS and +//! slice-header syntax. +//! +//! This is intentionally *not* a full H.264 parser. Vulkan Video decode +//! consumes raw slice NAL units and parses the reference-list machinery in the +//! driver; the host only needs enough syntax to +//! - populate `StdVideoH264SequenceParameterSet` / `StdVideoH264PictureParameterSet`, +//! - compute the picture order count (POC) and frame_num of each picture, and +//! - drive DPB slot management (IDR detection, reference flags). +//! +//! Accordingly the slice-header parser stops right after the POC syntax +//! elements; nothing later in the header is needed on the host side. + +use crate::decoder::bitreader::BitReader; +use crate::error::{PixelForgeError, Result}; + +/// H.264 NAL unit types this parser cares about. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum NalType { + /// Coded slice of a non-IDR picture (type 1). + Slice, + /// Coded slice of an IDR picture (type 5). + IdrSlice, + /// Supplemental enhancement information (type 6). + Sei, + /// Sequence parameter set (type 7). + Sps, + /// Picture parameter set (type 8). + Pps, + /// Access unit delimiter (type 9). + Aud, + /// Anything else. + Other(u8), +} + +impl NalType { + fn from_raw(raw: u8) -> Self { + match raw { + 1 => NalType::Slice, + 5 => NalType::IdrSlice, + 6 => NalType::Sei, + 7 => NalType::Sps, + 8 => NalType::Pps, + 9 => NalType::Aud, + other => NalType::Other(other), + } + } + + pub fn is_slice(self) -> bool { + matches!(self, NalType::Slice | NalType::IdrSlice) + } +} + +/// A single NAL unit within an Annex B stream. +#[derive(Debug, Clone, Copy)] +pub struct NalUnit<'a> { + pub nal_type: NalType, + /// `nal_ref_idc`: non-zero means this NAL is part of a reference picture. + pub ref_idc: u8, + /// The complete NAL unit (header byte + EBSP payload), without start code. + pub data: &'a [u8], +} + +impl<'a> NalUnit<'a> { + /// EBSP payload after the 1-byte NAL header. + pub fn payload(&self) -> &'a [u8] { + &self.data[1..] + } +} + +/// Iterate over NAL units in an Annex B byte stream (3- or 4-byte start codes). +pub fn iter_nal_units(data: &[u8]) -> impl Iterator> { + iter_nal_units_with_offsets(data).map(|(_, nal)| nal) +} + +/// Like [`iter_nal_units`], but also yields each NAL's start-code offset within +/// `data`. Used to slice the stream on access-unit boundaries. +pub(crate) fn iter_nal_units_with_offsets( + data: &[u8], +) -> impl Iterator)> { + NalIterator { data, pos: 0 } +} + +struct NalIterator<'a> { + data: &'a [u8], + pos: usize, +} + +/// Find the next start code at or after `from`. Returns (start_code_pos, payload_pos). +fn find_start_code(data: &[u8], from: usize) -> Option<(usize, usize)> { + let mut i = from; + while i + 3 <= data.len() { + if data[i] == 0 && data[i + 1] == 0 { + if data[i + 2] == 1 { + return Some((i, i + 3)); + } + if i + 4 <= data.len() && data[i + 2] == 0 && data[i + 3] == 1 { + return Some((i, i + 4)); + } + } + i += 1; + } + None +} + +impl<'a> Iterator for NalIterator<'a> { + type Item = (usize, NalUnit<'a>); + + fn next(&mut self) -> Option<(usize, NalUnit<'a>)> { + let (start_code_pos, payload_start) = find_start_code(self.data, self.pos)?; + let end = match find_start_code(self.data, payload_start) { + Some((next_start, _)) => next_start, + None => self.data.len(), + }; + self.pos = end; + + // Trim trailing zero padding before the next start code. + let mut trimmed_end = end; + while trimmed_end > payload_start && self.data[trimmed_end - 1] == 0 { + trimmed_end -= 1; + } + if trimmed_end <= payload_start { + return self.next(); + } + + let nal = &self.data[payload_start..trimmed_end]; + let header = nal[0]; + Some(( + start_code_pos, + NalUnit { + nal_type: NalType::from_raw(header & 0x1F), + ref_idc: (header >> 5) & 0x3, + data: nal, + }, + )) + } +} + +/// Parsed H.264 sequence parameter set (the subset Vulkan decode needs). +#[derive(Debug, Clone, Default)] +pub struct Sps { + pub profile_idc: u8, + pub constraint_set_flags: u8, + pub level_idc: u8, + pub sps_id: u8, + pub chroma_format_idc: u8, + pub separate_colour_plane_flag: bool, + pub bit_depth_luma_minus8: u8, + pub bit_depth_chroma_minus8: u8, + pub qpprime_y_zero_transform_bypass_flag: bool, + pub seq_scaling_matrix_present_flag: bool, + pub log2_max_frame_num_minus4: u8, + pub pic_order_cnt_type: u8, + pub log2_max_pic_order_cnt_lsb_minus4: u8, + pub delta_pic_order_always_zero_flag: bool, + pub offset_for_non_ref_pic: i32, + pub offset_for_top_to_bottom_field: i32, + pub offsets_for_ref_frame: Vec, + pub max_num_ref_frames: u8, + pub gaps_in_frame_num_value_allowed_flag: bool, + pub pic_width_in_mbs_minus1: u32, + pub pic_height_in_map_units_minus1: u32, + pub frame_mbs_only_flag: bool, + pub mb_adaptive_frame_field_flag: bool, + pub direct_8x8_inference_flag: bool, + pub frame_cropping_flag: bool, + pub frame_crop_left_offset: u32, + pub frame_crop_right_offset: u32, + pub frame_crop_top_offset: u32, + pub frame_crop_bottom_offset: u32, + /// `max_num_reorder_frames` from the VUI bitstream restriction, if present. + /// + /// The number of frames that may precede any frame in decode order yet + /// follow it in display order — i.e. the reorder buffer depth. `None` when + /// the stream does not signal it, in which case the decoder falls back to a + /// conservative bound. + pub max_num_reorder_frames: Option, +} + +impl Sps { + /// Coded width in pixels (before cropping). + pub fn coded_width(&self) -> u32 { + (self.pic_width_in_mbs_minus1 + 1) * 16 + } + + /// Coded height in pixels (before cropping). + pub fn coded_height(&self) -> u32 { + let map_units = self.pic_height_in_map_units_minus1 + 1; + let frame_height_in_mbs = if self.frame_mbs_only_flag { + map_units + } else { + map_units * 2 + }; + frame_height_in_mbs * 16 + } + + /// Display (cropped) dimensions. + pub fn display_dimensions(&self) -> (u32, u32) { + let (mut width, mut height) = (self.coded_width(), self.coded_height()); + if self.frame_cropping_flag { + let (crop_x, crop_y) = match self.chroma_format_idc { + 0 => (1, 1), + 1 => (2, 2), + 2 => (2, 1), + _ => (1, 1), + }; + let crop_y = crop_y * if self.frame_mbs_only_flag { 1 } else { 2 }; + width = width.saturating_sub( + crop_x * (self.frame_crop_left_offset + self.frame_crop_right_offset), + ); + height = height.saturating_sub( + crop_y * (self.frame_crop_top_offset + self.frame_crop_bottom_offset), + ); + } + (width, height) + } + + pub fn max_frame_num(&self) -> u32 { + 1 << (self.log2_max_frame_num_minus4 as u32 + 4) + } + + pub fn max_pic_order_cnt_lsb(&self) -> u32 { + 1 << (self.log2_max_pic_order_cnt_lsb_minus4 as u32 + 4) + } +} + +fn skip_scaling_list(r: &mut BitReader, size: usize) -> Result<()> { + let mut last_scale = 8i32; + let mut next_scale = 8i32; + for _ in 0..size { + if next_scale != 0 { + let delta = r.se()?; + next_scale = (last_scale + delta + 256) % 256; + } + if next_scale != 0 { + last_scale = next_scale; + } + } + Ok(()) +} + +/// Parse an SPS NAL payload (EBSP, after the NAL header byte). +pub fn parse_sps(payload: &[u8]) -> Result { + let mut r = BitReader::new(payload); + let mut sps = Sps { + profile_idc: r.bits(8)? as u8, + constraint_set_flags: r.bits(8)? as u8, + level_idc: r.bits(8)? as u8, + ..Default::default() + }; + sps.sps_id = r.ue()? as u8; + + // High-profile family carries chroma/bit-depth syntax. + sps.chroma_format_idc = 1; + if matches!( + sps.profile_idc, + 100 | 110 | 122 | 244 | 44 | 83 | 86 | 118 | 128 | 138 | 139 | 134 | 135 + ) { + sps.chroma_format_idc = r.ue()? as u8; + if sps.chroma_format_idc == 3 { + sps.separate_colour_plane_flag = r.flag()?; + } + sps.bit_depth_luma_minus8 = r.ue()? as u8; + sps.bit_depth_chroma_minus8 = r.ue()? as u8; + sps.qpprime_y_zero_transform_bypass_flag = r.flag()?; + sps.seq_scaling_matrix_present_flag = r.flag()?; + if sps.seq_scaling_matrix_present_flag { + let count = if sps.chroma_format_idc == 3 { 12 } else { 8 }; + for i in 0..count { + if r.flag()? { + skip_scaling_list(&mut r, if i < 6 { 16 } else { 64 })?; + } + } + } + } + + sps.log2_max_frame_num_minus4 = r.ue()? as u8; + sps.pic_order_cnt_type = r.ue()? as u8; + match sps.pic_order_cnt_type { + 0 => { + sps.log2_max_pic_order_cnt_lsb_minus4 = r.ue()? as u8; + } + 1 => { + sps.delta_pic_order_always_zero_flag = r.flag()?; + sps.offset_for_non_ref_pic = r.se()?; + sps.offset_for_top_to_bottom_field = r.se()?; + let num = r.ue()?; + for _ in 0..num { + sps.offsets_for_ref_frame.push(r.se()?); + } + } + 2 => {} + other => { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 SPS: invalid pic_order_cnt_type {}", + other + ))); + } + } + + sps.max_num_ref_frames = r.ue()? as u8; + sps.gaps_in_frame_num_value_allowed_flag = r.flag()?; + sps.pic_width_in_mbs_minus1 = r.ue()?; + sps.pic_height_in_map_units_minus1 = r.ue()?; + sps.frame_mbs_only_flag = r.flag()?; + if !sps.frame_mbs_only_flag { + sps.mb_adaptive_frame_field_flag = r.flag()?; + } + sps.direct_8x8_inference_flag = r.flag()?; + sps.frame_cropping_flag = r.flag()?; + if sps.frame_cropping_flag { + sps.frame_crop_left_offset = r.ue()?; + sps.frame_crop_right_offset = r.ue()?; + sps.frame_crop_top_offset = r.ue()?; + sps.frame_crop_bottom_offset = r.ue()?; + } + // The only VUI field the decoder needs is max_num_reorder_frames, which + // sits at the very end (in the bitstream restriction). Everything before it + // must be parsed to get there; a malformed/truncated VUI just leaves the + // value unset and the decoder falls back to a conservative bound. + if r.flag().unwrap_or(false) { + sps.max_num_reorder_frames = parse_vui_reorder(&mut r).ok().flatten(); + } + + Ok(sps) +} + +/// Parse `vui_parameters()` (Annex E.1.1) far enough to recover +/// `max_num_reorder_frames`. Every field before it is parsed only to advance. +fn parse_vui_reorder(r: &mut BitReader) -> Result> { + if r.flag()? { + // aspect_ratio_info_present_flag + let aspect_ratio_idc = r.bits(8)?; + // Extended_SAR + if aspect_ratio_idc == 255 { + let _sar_width = r.bits(16)?; + let _sar_height = r.bits(16)?; + } + } + if r.flag()? { + // overscan_appropriate_flag + let _overscan_appropriate_flag = r.flag()?; + } + if r.flag()? { + // video_signal_type_present_flag + let _video_format = r.bits(3)?; + let _video_full_range_flag = r.flag()?; + if r.flag()? { + // colour_description_present_flag + let _colour_primaries = r.bits(8)?; + let _transfer_characteristics = r.bits(8)?; + let _matrix_coefficients = r.bits(8)?; + } + } + if r.flag()? { + // chroma_loc_info_present_flag + let _top = r.ue()?; + let _bottom = r.ue()?; + } + if r.flag()? { + // timing_info_present_flag + let _num_units_in_tick = r.bits(32)?; + let _time_scale = r.bits(32)?; + let _fixed_frame_rate_flag = r.flag()?; + } + let nal_hrd = r.flag()?; + if nal_hrd { + parse_hrd(r)?; + } + let vcl_hrd = r.flag()?; + if vcl_hrd { + parse_hrd(r)?; + } + if nal_hrd || vcl_hrd { + let _low_delay_hrd_flag = r.flag()?; + } + let _pic_struct_present_flag = r.flag()?; + + if r.flag()? { + // bitstream_restriction_flag + let _motion_vectors_over_pic_boundaries_flag = r.flag()?; + let _max_bytes_per_pic_denom = r.ue()?; + let _max_bits_per_mb_denom = r.ue()?; + let _log2_max_mv_length_horizontal = r.ue()?; + let _log2_max_mv_length_vertical = r.ue()?; + let max_num_reorder_frames = r.ue()?; + let _max_dec_frame_buffering = r.ue()?; + return Ok(Some(max_num_reorder_frames)); + } + + Ok(None) +} + +/// Parse `hrd_parameters()` (Annex E.1.2). Consumed only to advance past it. +fn parse_hrd(r: &mut BitReader) -> Result<()> { + let cpb_cnt_minus1 = r.ue()?; + let _bit_rate_scale = r.bits(4)?; + let _cpb_size_scale = r.bits(4)?; + for _ in 0..=cpb_cnt_minus1 { + let _bit_rate_value_minus1 = r.ue()?; + let _cpb_size_value_minus1 = r.ue()?; + let _cbr_flag = r.flag()?; + } + let _initial_cpb_removal_delay_length_minus1 = r.bits(5)?; + let _cpb_removal_delay_length_minus1 = r.bits(5)?; + let _dpb_output_delay_length_minus1 = r.bits(5)?; + let _time_offset_length = r.bits(5)?; + Ok(()) +} + +/// Parsed H.264 picture parameter set (the subset Vulkan decode needs). +#[derive(Debug, Clone, Default)] +pub struct Pps { + pub pps_id: u8, + pub sps_id: u8, + pub entropy_coding_mode_flag: bool, + pub bottom_field_pic_order_in_frame_present_flag: bool, + pub num_slice_groups_minus1: u32, + pub num_ref_idx_l0_default_active_minus1: u8, + pub num_ref_idx_l1_default_active_minus1: u8, + pub weighted_pred_flag: bool, + pub weighted_bipred_idc: u8, + pub pic_init_qp_minus26: i8, + pub pic_init_qs_minus26: i8, + pub chroma_qp_index_offset: i8, + pub deblocking_filter_control_present_flag: bool, + pub constrained_intra_pred_flag: bool, + pub redundant_pic_cnt_present_flag: bool, + pub transform_8x8_mode_flag: bool, + pub pic_scaling_matrix_present_flag: bool, + pub second_chroma_qp_index_offset: i8, +} + +/// Parse a PPS NAL payload (EBSP, after the NAL header byte). +pub fn parse_pps(payload: &[u8], sps_chroma_format_idc: impl Fn(u8) -> Option) -> Result { + let mut r = BitReader::new(payload); + let mut pps = Pps { + pps_id: r.ue()? as u8, + sps_id: r.ue()? as u8, + ..Default::default() + }; + pps.entropy_coding_mode_flag = r.flag()?; + pps.bottom_field_pic_order_in_frame_present_flag = r.flag()?; + pps.num_slice_groups_minus1 = r.ue()?; + if pps.num_slice_groups_minus1 > 0 { + // FMO is exotic and not supported by any Vulkan Video implementation. + return Err(PixelForgeError::InvalidInput( + "H.264 PPS: slice groups (FMO) are not supported".to_string(), + )); + } + pps.num_ref_idx_l0_default_active_minus1 = r.ue()? as u8; + pps.num_ref_idx_l1_default_active_minus1 = r.ue()? as u8; + pps.weighted_pred_flag = r.flag()?; + pps.weighted_bipred_idc = r.bits(2)? as u8; + pps.pic_init_qp_minus26 = r.se()? as i8; + pps.pic_init_qs_minus26 = r.se()? as i8; + pps.chroma_qp_index_offset = r.se()? as i8; + pps.deblocking_filter_control_present_flag = r.flag()?; + pps.constrained_intra_pred_flag = r.flag()?; + pps.redundant_pic_cnt_present_flag = r.flag()?; + + // Optional trailing fields (present in High profile streams). Detect by + // attempting to read; `more_rbsp_data` is approximated by whether reads + // succeed and the stop bit hasn't been consumed. We conservatively try and + // fall back to defaults on end-of-data. + pps.second_chroma_qp_index_offset = pps.chroma_qp_index_offset; + if let Ok(transform_8x8) = r.flag() { + // Distinguish real data from the RBSP stop bit: the stop bit is a `1` + // followed only by zero padding. If everything after this flag fails to + // parse, treat it as the stop bit. + let mut tail = || -> Result<(bool, bool, i8)> { + let scaling = r.flag()?; + if scaling { + let chroma_idc = sps_chroma_format_idc(pps.sps_id).unwrap_or(1); + let count = 6 + if chroma_idc == 3 { 6 } else { 2 }; + for i in 0..count { + if r.flag()? { + skip_scaling_list(&mut r, if i < 6 { 16 } else { 64 })?; + } + } + } + let second_offset = r.se()? as i8; + Ok((transform_8x8, scaling, second_offset)) + }; + if let Ok((t8, scaling, second)) = tail() { + pps.transform_8x8_mode_flag = t8; + pps.pic_scaling_matrix_present_flag = scaling; + pps.second_chroma_qp_index_offset = second; + } + } + + Ok(pps) +} + +/// H.264 slice types (values already reduced modulo 5). +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum SliceType { + P, + B, + I, + Sp, + Si, +} + +/// A memory management control operation (clause 7.4.3.3). +/// +/// Operand names follow the standard. All picture numbers are frame-based: +/// field decoding is rejected before any of this is reached. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Mmco { + /// 1: mark a short-term reference as unused. + ForgetShort { difference_of_pic_nums_minus1: u32 }, + /// 2: mark a long-term reference as unused. + ForgetLong { long_term_pic_num: u32 }, + /// 3: turn a short-term reference into a long-term one. + ShortToLong { + difference_of_pic_nums_minus1: u32, + long_term_frame_idx: u32, + }, + /// 4: set the upper bound on long-term frame indices. + MaxLongTermIdx { max_long_term_frame_idx_plus1: u32 }, + /// 5: mark every reference unused and reset frame_num/POC. + ForgetAll, + /// 6: mark the current picture as a long-term reference. + CurrentToLong { long_term_frame_idx: u32 }, +} + +/// `dec_ref_pic_marking()` (clause 7.3.3.3). +/// +/// Only present when `nal_ref_idc != 0`; a default value therefore means "this +/// picture is not a reference and marks nothing". +#[derive(Debug, Clone, Default, PartialEq, Eq)] +pub struct RefPicMarking { + /// IDR only: the IDR itself becomes a long-term reference. + pub long_term_reference_flag: bool, + /// IDR only. Retained for completeness; output order is not driven by it. + pub no_output_of_prior_pics_flag: bool, + /// Non-IDR: explicit marking is in use, so `ops` replaces the sliding + /// window. When false the sliding-window process applies (clause 8.2.5.3). + pub adaptive: bool, + /// The MMCO ops, in bitstream order. Terminating op 0 is not stored. + pub ops: Vec, +} + +/// The slice-header fields needed for picture-level decode bookkeeping. +/// +/// Parsing runs as far as `dec_ref_pic_marking()`, since reference marking is +/// the decoder's responsibility; the syntax in between is parsed only to reach +/// it. Everything after is consumed by the driver from the raw slice NAL. +#[derive(Debug, Clone)] +pub struct SliceHeader { + pub first_mb_in_slice: u32, + pub slice_type: SliceType, + pub pps_id: u8, + pub frame_num: u32, + pub field_pic_flag: bool, + pub idr_pic_id: u16, + pub pic_order_cnt_lsb: u32, + pub delta_pic_order_cnt: [i32; 2], + pub marking: RefPicMarking, +} + +/// Parse the leading portion of a slice header. +pub fn parse_slice_header(nal: &NalUnit, sps: &Sps, pps: &Pps) -> Result { + let mut r = BitReader::new(nal.payload()); + let first_mb_in_slice = r.ue()?; + let slice_type_raw = r.ue()?; + let slice_type = match slice_type_raw % 5 { + 0 => SliceType::P, + 1 => SliceType::B, + 2 => SliceType::I, + 3 => SliceType::Sp, + 4 => SliceType::Si, + _ => unreachable!(), + }; + let pps_id = r.ue()? as u8; + + if sps.separate_colour_plane_flag { + let _colour_plane_id = r.bits(2)?; + } + + let frame_num = r.bits(sps.log2_max_frame_num_minus4 as u32 + 4)?; + + // Field coding is rejected before decode; bottom_field_flag is parsed only + // to stay bit-aligned, never stored. + let mut field_pic_flag = false; + if !sps.frame_mbs_only_flag { + field_pic_flag = r.flag()?; + if field_pic_flag { + let _bottom_field_flag = r.flag()?; + } + } + + let mut idr_pic_id = 0u16; + if nal.nal_type == NalType::IdrSlice { + idr_pic_id = r.ue()? as u16; + } + + let mut pic_order_cnt_lsb = 0u32; + let mut delta_pic_order_cnt = [0i32; 2]; + match sps.pic_order_cnt_type { + 0 => { + pic_order_cnt_lsb = r.bits(sps.log2_max_pic_order_cnt_lsb_minus4 as u32 + 4)?; + if pps.bottom_field_pic_order_in_frame_present_flag && !field_pic_flag { + // Bottom-field POC delta: parsed for alignment, unused for frames. + let _delta_pic_order_cnt_bottom = r.se()?; + } + } + 1 if !sps.delta_pic_order_always_zero_flag => { + delta_pic_order_cnt[0] = r.se()?; + if pps.bottom_field_pic_order_in_frame_present_flag && !field_pic_flag { + delta_pic_order_cnt[1] = r.se()?; + } + } + _ => {} + } + + // From here the only field we need is dec_ref_pic_marking(), but it is not + // at a fixed offset: everything before it must be parsed to find it. + if pps.redundant_pic_cnt_present_flag { + let _redundant_pic_cnt = r.ue()?; + } + if slice_type == SliceType::B { + let _direct_spatial_mv_pred_flag = r.flag()?; + } + + let mut num_ref_idx_l0_active_minus1 = pps.num_ref_idx_l0_default_active_minus1 as u32; + let mut num_ref_idx_l1_active_minus1 = pps.num_ref_idx_l1_default_active_minus1 as u32; + if matches!(slice_type, SliceType::P | SliceType::Sp | SliceType::B) { + if r.flag()? { + num_ref_idx_l0_active_minus1 = r.ue()?; + if slice_type == SliceType::B { + num_ref_idx_l1_active_minus1 = r.ue()?; + } + } + // Clause 7.4.3: the value is at most 31. A larger one means we have + // lost bit alignment, and would otherwise drive a huge parse loop. + if num_ref_idx_l0_active_minus1 > 31 || num_ref_idx_l1_active_minus1 > 31 { + return Err(PixelForgeError::InvalidInput( + "H.264 decode: num_ref_idx_active_minus1 out of range".to_string(), + )); + } + } + + parse_ref_pic_list_modification(&mut r, slice_type)?; + + let chroma_array_type = if sps.separate_colour_plane_flag { + 0 + } else { + sps.chroma_format_idc + }; + let weighted = match slice_type { + SliceType::P | SliceType::Sp => pps.weighted_pred_flag, + SliceType::B => pps.weighted_bipred_idc == 1, + _ => false, + }; + if weighted { + parse_pred_weight_table( + &mut r, + chroma_array_type, + num_ref_idx_l0_active_minus1, + num_ref_idx_l1_active_minus1, + slice_type == SliceType::B, + )?; + } + + let marking = if nal.ref_idc != 0 { + parse_dec_ref_pic_marking(&mut r, nal.nal_type == NalType::IdrSlice)? + } else { + RefPicMarking::default() + }; + + Ok(SliceHeader { + first_mb_in_slice, + slice_type, + pps_id, + frame_num, + field_pic_flag, + idr_pic_id, + pic_order_cnt_lsb, + delta_pic_order_cnt, + marking, + }) +} + +/// `ref_pic_list_modification()` (clause 7.3.3.1). +/// +/// The reordered lists themselves are rebuilt by the driver from the slice +/// data, so the syntax is parsed only to advance past it. +fn parse_ref_pic_list_modification(r: &mut BitReader, slice_type: SliceType) -> Result<()> { + let modification_list = |r: &mut BitReader| -> Result<()> { + if !r.flag()? { + return Ok(()); + } + loop { + match r.ue()? { + // abs_diff_pic_num_minus1 / long_term_pic_num + 0..=2 => { + let _ = r.ue()?; + } + 3 => return Ok(()), + other => { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: invalid modification_of_pic_nums_idc {}", + other + ))); + } + } + } + }; + + if !matches!(slice_type, SliceType::I | SliceType::Si) { + modification_list(r)?; + } + if slice_type == SliceType::B { + modification_list(r)?; + } + Ok(()) +} + +/// `pred_weight_table()` (clause 7.3.3.2). Parsed only to advance past it. +fn parse_pred_weight_table( + r: &mut BitReader, + chroma_array_type: u8, + num_ref_idx_l0_active_minus1: u32, + num_ref_idx_l1_active_minus1: u32, + is_b: bool, +) -> Result<()> { + let _luma_log2_weight_denom = r.ue()?; + if chroma_array_type != 0 { + let _chroma_log2_weight_denom = r.ue()?; + } + + let weights = |r: &mut BitReader, count: u32| -> Result<()> { + for _ in 0..=count { + if r.flag()? { + let _luma_weight = r.se()?; + let _luma_offset = r.se()?; + } + if chroma_array_type != 0 && r.flag()? { + for _ in 0..2 { + let _chroma_weight = r.se()?; + let _chroma_offset = r.se()?; + } + } + } + Ok(()) + }; + + weights(r, num_ref_idx_l0_active_minus1)?; + if is_b { + weights(r, num_ref_idx_l1_active_minus1)?; + } + Ok(()) +} + +/// `dec_ref_pic_marking()` (clause 7.3.3.3). +fn parse_dec_ref_pic_marking(r: &mut BitReader, is_idr: bool) -> Result { + let mut marking = RefPicMarking::default(); + if is_idr { + marking.no_output_of_prior_pics_flag = r.flag()?; + marking.long_term_reference_flag = r.flag()?; + return Ok(marking); + } + + marking.adaptive = r.flag()?; + if !marking.adaptive { + return Ok(marking); + } + + loop { + let op = r.ue()?; + let op = match op { + 0 => return Ok(marking), + 1 => Mmco::ForgetShort { + difference_of_pic_nums_minus1: r.ue()?, + }, + 2 => Mmco::ForgetLong { + long_term_pic_num: r.ue()?, + }, + 3 => Mmco::ShortToLong { + difference_of_pic_nums_minus1: r.ue()?, + long_term_frame_idx: r.ue()?, + }, + 4 => Mmco::MaxLongTermIdx { + max_long_term_frame_idx_plus1: r.ue()?, + }, + 5 => Mmco::ForgetAll, + 6 => Mmco::CurrentToLong { + long_term_frame_idx: r.ue()?, + }, + other => { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: invalid memory_management_control_operation {}", + other + ))); + } + }; + marking.ops.push(op); + + // A conforming stream cannot mark more pictures than the DPB can hold; + // this bounds the loop if the bitstream is corrupt. + if marking.ops.len() > 64 { + return Err(PixelForgeError::InvalidInput( + "H.264 decode: runaway dec_ref_pic_marking".to_string(), + )); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_nal_iteration() { + // 4-byte start code + SPS header byte, 3-byte start code + PPS header byte. + let data = [ + 0x00, 0x00, 0x00, 0x01, 0x67, 0xAA, // SPS (type 7, ref_idc 3) + 0x00, 0x00, 0x01, 0x68, 0xBB, // PPS (type 8, ref_idc 3) + 0x00, 0x00, 0x01, 0x65, 0xCC, // IDR slice (type 5) + ]; + let nals: Vec<_> = iter_nal_units(&data).collect(); + assert_eq!(nals.len(), 3); + assert_eq!(nals[0].nal_type, NalType::Sps); + assert_eq!(nals[0].ref_idc, 3); + assert_eq!(nals[0].data, &[0x67, 0xAA]); + assert_eq!(nals[1].nal_type, NalType::Pps); + assert_eq!(nals[2].nal_type, NalType::IdrSlice); + assert!(nals[2].nal_type.is_slice()); + } + + #[test] + fn test_parse_x264_sps_pps() { + // Verbatim SPS/PPS from an x264-encoded 320x240 High-profile stream + // (the same encoder settings as tests/data/bframes.264). Note the + // 0x000003 emulation-prevention sequences in the VUI. + let sps_payload = [ + 0x64, 0x00, 0x0d, 0xac, 0xd9, 0x41, 0x41, 0xfa, 0x10, 0x00, 0x00, 0x03, 0x00, 0x10, + 0x00, 0x00, 0x03, 0x03, 0xc8, 0xf1, 0x42, 0x99, 0x60, + ]; + let sps = parse_sps(&sps_payload).unwrap(); + assert_eq!(sps.profile_idc, 100); + assert_eq!(sps.level_idc, 13); + assert_eq!(sps.chroma_format_idc, 1); + assert_eq!(sps.coded_width(), 320); + assert_eq!(sps.coded_height(), 240); + assert_eq!(sps.display_dimensions(), (320, 240)); + assert!(sps.frame_mbs_only_flag); + + // Matching PPS: 68 eb e3 cb 22 c0 + let pps_payload = [0xeb, 0xe3, 0xcb, 0x22, 0xc0]; + let pps = parse_pps(&pps_payload, |_| Some(sps.chroma_format_idc)).unwrap(); + assert_eq!(pps.pps_id, 0); + assert_eq!(pps.sps_id, 0); + assert!(pps.entropy_coding_mode_flag); // CABAC + } +} diff --git a/src/decoder/h264/parser_tests.rs b/src/decoder/h264/parser_tests.rs new file mode 100644 index 0000000..7c9556b --- /dev/null +++ b/src/decoder/h264/parser_tests.rs @@ -0,0 +1,403 @@ +//! Parser tests against real x264-generated streams. +//! +//! The fixtures in `tests/data` are 320x240, 30-frame streams produced by +//! x264. Between them they cover the Baseline and High profiles, POC types 0 +//! and 2, and streams with and without B-frames. +//! +//! These tests exercise the host-side parsing and DPB bookkeeping only; they +//! need no GPU and so run everywhere. + +use super::parser::{Mmco, NalType, iter_nal_units, parse_pps, parse_slice_header, parse_sps}; + +const BASELINE: &[u8] = include_bytes!("../../../tests/data/base.264"); +const ZEROLATENCY: &[u8] = include_bytes!("../../../tests/data/zerolatency.264"); +const BFRAMES: &[u8] = include_bytes!("../../../tests/data/bframes.264"); +/// 4 slices per picture: distinguishes "split per slice" from "split per picture". +const MULTISLICE: &[u8] = include_bytes!("../../../tests/data/multislice.264"); + +/// Every fixture: one SPS, one PPS, 30 slices, first slice is an IDR. +#[test] +fn parses_stream_structure() { + for (name, data) in [ + ("baseline", BASELINE), + ("zerolatency", ZEROLATENCY), + ("bframes", BFRAMES), + ] { + let nals: Vec<_> = iter_nal_units(data).collect(); + let sps_count = nals.iter().filter(|n| n.nal_type == NalType::Sps).count(); + let pps_count = nals.iter().filter(|n| n.nal_type == NalType::Pps).count(); + let slices: Vec<_> = nals.iter().filter(|n| n.nal_type.is_slice()).collect(); + + assert_eq!(sps_count, 1, "{name}: expected one SPS"); + assert_eq!(pps_count, 1, "{name}: expected one PPS"); + assert_eq!(slices.len(), 30, "{name}: expected 30 slices"); + assert_eq!( + slices[0].nal_type, + NalType::IdrSlice, + "{name}: stream must open with an IDR" + ); + // An IDR is always a reference picture. + assert_ne!(slices[0].ref_idc, 0, "{name}: IDR must have ref_idc != 0"); + } +} + +/// The SPS decodes to the dimensions and profile x264 was asked for. +#[test] +fn parses_sps_geometry_and_profile() { + for (name, data, expected_profile) in [ + ("baseline", BASELINE, 66u8), + ("zerolatency", ZEROLATENCY, 100), + ("bframes", BFRAMES, 100), + ] { + let sps_nal = iter_nal_units(data) + .find(|n| n.nal_type == NalType::Sps) + .expect("stream has an SPS"); + let sps = parse_sps(sps_nal.payload()).expect("SPS parses"); + + assert_eq!(sps.profile_idc, expected_profile, "{name}: profile_idc"); + assert_eq!(sps.sps_id, 0, "{name}: sps_id"); + assert_eq!(sps.chroma_format_idc, 1, "{name}: 4:2:0"); + assert_eq!(sps.bit_depth_luma_minus8, 0, "{name}: 8-bit luma"); + assert!(sps.frame_mbs_only_flag, "{name}: progressive"); + // 320x240 is exactly 20x15 macroblocks, so no cropping is needed. + assert_eq!(sps.coded_width(), 320, "{name}: coded width"); + assert_eq!(sps.coded_height(), 240, "{name}: coded height"); + assert_eq!(sps.display_dimensions(), (320, 240), "{name}: display size"); + } +} + +/// A stream whose dimensions aren't macroblock-aligned must report cropping. +#[test] +fn parses_cropped_dimensions() { + // The fixtures are all MB-aligned, so verify the crop arithmetic against a + // synthetic SPS: 4:2:0 crop offsets are in chroma units (2 luma samples). + let sps_nal = iter_nal_units(BFRAMES) + .find(|n| n.nal_type == NalType::Sps) + .unwrap(); + let mut sps = parse_sps(sps_nal.payload()).unwrap(); + assert!(!sps.frame_cropping_flag); + + // Crop 1920x1080-style: 68 rows of padding -> 240 - 8 = 232. + sps.frame_cropping_flag = true; + sps.frame_crop_bottom_offset = 4; // 4 * 2 = 8 luma rows + sps.frame_crop_right_offset = 2; // 2 * 2 = 4 luma columns + assert_eq!(sps.display_dimensions(), (316, 232)); + assert_eq!(sps.coded_width(), 320, "cropping must not alter coded size"); +} + +/// The PPS parses, and its entropy mode matches the profile x264 used. +#[test] +fn parses_pps() { + for (name, data, expect_cabac) in [ + // Baseline profile cannot use CABAC. + ("baseline", BASELINE, false), + ("zerolatency", ZEROLATENCY, true), + ("bframes", BFRAMES, true), + ] { + let sps_nal = iter_nal_units(data) + .find(|n| n.nal_type == NalType::Sps) + .unwrap(); + let sps = parse_sps(sps_nal.payload()).unwrap(); + let pps_nal = iter_nal_units(data) + .find(|n| n.nal_type == NalType::Pps) + .unwrap(); + let pps = + parse_pps(pps_nal.payload(), |_| Some(sps.chroma_format_idc)).expect("PPS parses"); + + assert_eq!(pps.pps_id, 0, "{name}: pps_id"); + assert_eq!(pps.sps_id, 0, "{name}: references sps 0"); + assert_eq!( + pps.entropy_coding_mode_flag, expect_cabac, + "{name}: entropy coding mode" + ); + assert_eq!(pps.num_slice_groups_minus1, 0, "{name}: no FMO"); + } +} + +/// Slice headers parse across the whole stream, and frame_num advances the way +/// the reference structure implies. +#[test] +fn parses_all_slice_headers() { + for (name, data) in [ + ("baseline", BASELINE), + ("zerolatency", ZEROLATENCY), + ("bframes", BFRAMES), + ] { + let sps_nal = iter_nal_units(data) + .find(|n| n.nal_type == NalType::Sps) + .unwrap(); + let sps = parse_sps(sps_nal.payload()).unwrap(); + let pps_nal = iter_nal_units(data) + .find(|n| n.nal_type == NalType::Pps) + .unwrap(); + let pps = parse_pps(pps_nal.payload(), |_| Some(sps.chroma_format_idc)).unwrap(); + + let mut headers = Vec::new(); + for nal in iter_nal_units(data).filter(|n| n.nal_type.is_slice()) { + let header = parse_slice_header(&nal, &sps, &pps) + .unwrap_or_else(|e| panic!("{name}: slice header must parse: {e}")); + assert_eq!(header.pps_id, 0, "{name}: slice references pps 0"); + assert!(!header.field_pic_flag, "{name}: progressive stream"); + assert!( + header.frame_num < sps.max_frame_num(), + "{name}: frame_num must fit in log2_max_frame_num" + ); + headers.push((header, nal.ref_idc)); + } + + assert_eq!(headers.len(), 30, "{name}: all slices parsed"); + // The IDR resets frame_num to 0. + assert_eq!(headers[0].0.frame_num, 0, "{name}: IDR has frame_num 0"); + + // frame_num only advances after a *reference* picture. Verify that + // invariant holds across the entire stream. + for pair in headers.windows(2) { + let (prev, prev_ref_idc) = &pair[0]; + let (next, _) = &pair[1]; + let expected = if *prev_ref_idc != 0 { + (prev.frame_num + 1) % sps.max_frame_num() + } else { + prev.frame_num + }; + assert_eq!( + next.frame_num, expected, + "{name}: frame_num must advance only after reference pictures" + ); + } + } +} + +/// x264's B-pyramid retires its B-references with explicit MMCO commands rather +/// than leaving them to the sliding window. Parsing `dec_ref_pic_marking()` is +/// therefore not optional: ignoring it silently corrupts the DPB, and the +/// decoded pictures with it. +/// +/// This pins both halves: the B-pyramid fixtures really do carry MMCO, and the +/// streams without B-references really do not. +#[test] +fn parses_explicit_reference_marking() { + fn markings(data: &[u8]) -> Vec<(bool, Vec)> { + let sps_nal = iter_nal_units(data) + .find(|n| n.nal_type == NalType::Sps) + .unwrap(); + let sps = parse_sps(sps_nal.payload()).unwrap(); + let pps_nal = iter_nal_units(data) + .find(|n| n.nal_type == NalType::Pps) + .unwrap(); + let pps = parse_pps(pps_nal.payload(), |_| Some(sps.chroma_format_idc)).unwrap(); + + iter_nal_units(data) + .filter(|n| n.nal_type.is_slice()) + .map(|nal| { + let h = parse_slice_header(&nal, &sps, &pps).unwrap(); + (h.marking.adaptive, h.marking.ops.clone()) + }) + .collect() + } + + // B-pyramid: at least one picture marks a short-term reference unused. + for (name, data) in [("bframes", BFRAMES), ("multislice", MULTISLICE)] { + let markings = markings(data); + assert!( + markings.iter().any(|(adaptive, _)| *adaptive), + "{name}: B-pyramid stream must use adaptive reference marking" + ); + assert!( + markings + .iter() + .flat_map(|(_, ops)| ops) + .any(|op| matches!(op, Mmco::ForgetShort { .. })), + "{name}: expected an MMCO 1 retiring a short-term reference" + ); + } + + // No B-references: pure sliding window, so no MMCO anywhere. + for (name, data) in [("baseline", BASELINE), ("zerolatency", ZEROLATENCY)] { + for (adaptive, ops) in markings(data) { + assert!( + !adaptive && ops.is_empty(), + "{name}: expected sliding-window marking only" + ); + } + } +} + +/// The B-frame stream really does contain disposable (non-reference) pictures, +/// and the zerolatency one does not. This is what makes the two fixtures +/// distinct tests rather than duplicates. +#[test] +fn fixtures_differ_in_reference_structure() { + let disposable = |data: &[u8]| { + iter_nal_units(data) + .filter(|n| n.nal_type.is_slice()) + .filter(|n| n.ref_idc == 0) + .count() + }; + + assert!( + disposable(BFRAMES) > 0, + "the bframes fixture should contain non-reference B-frames" + ); + assert_eq!( + disposable(ZEROLATENCY), + 0, + "a zerolatency stream should have every picture as a reference" + ); +} + +/// POC type 0 streams carry an explicit pic_order_cnt_lsb; POC type 2 streams +/// derive POC from decode order and carry none. +#[test] +fn poc_type_matches_stream_structure() { + let sps_of = |data: &[u8]| { + let nal = iter_nal_units(data) + .find(|n| n.nal_type == NalType::Sps) + .unwrap(); + parse_sps(nal.payload()).unwrap() + }; + + // These exact values are cross-checked against ffprobe, which reports + // has_b_frames=0 for the first two fixtures and 2 for the third. + // + // x264 picks POC type 2 when no reordering is needed (POC == decode + // order), and type 0 when B-frames force display order to diverge. The two + // cases exercise both POC paths in the decoder's DPB. + assert_eq!( + sps_of(BASELINE).pic_order_cnt_type, + 2, + "a stream without B-frames should use POC type 2" + ); + assert_eq!( + sps_of(ZEROLATENCY).pic_order_cnt_type, + 2, + "a zerolatency stream should use POC type 2" + ); + assert_eq!( + sps_of(BFRAMES).pic_order_cnt_type, + 0, + "a stream with B-frames must carry explicit POC (type 0)" + ); +} + +/// Level and reference-frame counts match what ffprobe reports for these files. +#[test] +fn parses_level_and_ref_frames() { + let sps_of = |data: &[u8]| { + let nal = iter_nal_units(data) + .find(|n| n.nal_type == NalType::Sps) + .unwrap(); + parse_sps(nal.payload()).unwrap() + }; + + for (name, data, expected_refs) in [ + ("baseline", BASELINE, 3u8), + ("zerolatency", ZEROLATENCY, 3), + ("bframes", BFRAMES, 4), + ] { + let sps = sps_of(data); + // ffprobe reports level=13 (i.e. level 1.3) for all three fixtures. + assert_eq!(sps.level_idc, 13, "{name}: level_idc"); + assert_eq!(sps.max_num_ref_frames, expected_refs, "{name}: ref frames"); + // The decoder sizes its DPB from this; it must fit the spec maximum. + assert!(sps.max_num_ref_frames <= 16, "{name}: ref frames in range"); + } +} + +/// `access_units` must split a stream into exactly one unit per coded picture, +/// losing no bytes and keeping parameter sets with the picture they configure. +#[test] +fn splits_stream_into_access_units() { + for (name, data) in [ + ("baseline", BASELINE), + ("zerolatency", ZEROLATENCY), + ("bframes", BFRAMES), + ("multislice", MULTISLICE), + ] { + let units: Vec<_> = crate::decoder::access_units(data).collect(); + + // Each fixture is 30 coded pictures. + assert_eq!(units.len(), 30, "{name}: one access unit per picture"); + + // Every unit holds exactly one picture-start slice. + for (i, unit) in units.iter().enumerate() { + let picture_starts = iter_nal_units(unit) + .filter(|n| n.nal_type.is_slice()) + .filter(|n| n.payload().first().is_some_and(|b| b & 0x80 != 0)) + .count(); + assert_eq!( + picture_starts, 1, + "{name}: unit {i} must contain exactly one picture" + ); + } + + // The split must be lossless and contiguous: concatenating the units + // reproduces the stream from the first unit's start onward. + let total: usize = units.iter().map(|u| u.len()).sum(); + let first_offset = data.len() - total; + let rejoined: Vec = units.concat(); + assert_eq!( + rejoined, + &data[first_offset..], + "{name}: units must tile the stream without gaps or overlap" + ); + + // The SPS and PPS must travel with the first picture, or the decoder + // could not configure a session before the IDR. + let first = units[0]; + assert!( + iter_nal_units(first).any(|n| n.nal_type == NalType::Sps), + "{name}: first access unit must carry the SPS" + ); + assert!( + iter_nal_units(first).any(|n| n.nal_type == NalType::Pps), + "{name}: first access unit must carry the PPS" + ); + assert!( + iter_nal_units(first).any(|n| n.nal_type == NalType::IdrSlice), + "{name}: first access unit must be the IDR" + ); + } +} + +/// A picture split across several slice NALs must still form a single access +/// unit. Without this fixture, splitting per slice and splitting per picture +/// are indistinguishable, since x264 emits one slice per picture by default. +#[test] +fn multislice_pictures_form_one_access_unit() { + let slice_nals = iter_nal_units(MULTISLICE) + .filter(|n| n.nal_type.is_slice()) + .count(); + let units: Vec<_> = crate::decoder::access_units(MULTISLICE).collect(); + + // The fixture really is multi-slice: 4 slices per picture, 30 pictures. + assert_eq!(slice_nals, 120, "fixture should hold 120 slice NALs"); + assert_eq!(units.len(), 30, "but only 30 access units"); + + // Every unit carries all 4 slices of its picture. + for (i, unit) in units.iter().enumerate() { + let slices = iter_nal_units(unit) + .filter(|n| n.nal_type.is_slice()) + .count(); + assert_eq!(slices, 4, "access unit {i} should hold all 4 slices"); + } +} + +#[test] +fn vui_reorder_matches_streams() { + // (stream, expected max_num_reorder_frames present and value) + for (name, data, expect) in [ + ("baseline", BASELINE, Some(0u32)), + ("zerolatency", ZEROLATENCY, Some(0)), + ("bframes", BFRAMES, Some(2)), + ("multislice", MULTISLICE, Some(2)), + ] { + let sps_nal = iter_nal_units(data) + .find(|n| n.nal_type == NalType::Sps) + .unwrap(); + let sps = parse_sps(sps_nal.payload()).unwrap(); + // Only assert when the stream signals it; if None, skip (fallback path). + if let Some(v) = expect { + assert_eq!(sps.max_num_reorder_frames, Some(v), "{name}: reorder depth"); + } + } +} diff --git a/src/decoder/h264/session.rs b/src/decoder/h264/session.rs new file mode 100644 index 0000000..ea05f4c --- /dev/null +++ b/src/decoder/h264/session.rs @@ -0,0 +1,1053 @@ +//! The H.264 Vulkan decode session: lazy stream-driven setup, per-picture +//! record/submit, and CPU readback. + +use std::collections::HashMap; + +use ash::vk; +use ash::vk::TaggedStructure; +use tracing::debug; + +use crate::decoder::codec::{DecoderCommon, ReorderBuffer, SessionPlan, query_decode_caps}; +use crate::decoder::h264::dpb::{DecodeDpb, PictureState}; +use crate::decoder::h264::parser::{ + self, NalType, NalUnit, Pps, SliceHeader, SliceType, Sps, iter_nal_units, +}; +use crate::decoder::{DecodedFrame, DecodedFrameData}; +use crate::encoder::{BitDepth, PixelFormat}; +use crate::error::{PixelForgeError, Result}; +use crate::video::{align_up, get_video_format, query_supported_video_formats}; +use crate::vulkan::VideoContext; + +/// H.264 decoder. +/// +/// Owns only what is specific to H.264: the parameter sets seen so far, and the +/// POC / reference-marking state. Everything Vulkan lives in [`DecoderCommon`]. +pub(crate) struct H264Decoder { + common: DecoderCommon, + + /// Parameter sets seen so far, by id. + sps_map: HashMap, + pps_map: HashMap, + + /// The SPS/PPS the current session parameters were built from, so a change + /// that matters can be detected. + active_sps_id: u8, + active_pps_id: u8, + + /// POC and reference state, recreated alongside the session. + dpb: Option, + + /// Display-order reordering (a pass-through in decode-order mode). + reorder: ReorderBuffer, + /// `max_num_reorder_frames` for the active SPS: how deep the reorder buffer + /// may hold pictures before emitting. + reorder_depth: usize, + + /// Whether decoding is waiting for a keyframe (IDR) before it can produce + /// output. True until the first IDR is decoded: non-IDR pictures reference + /// state that does not exist yet, so they are skipped and the caller is told + /// to request an IDR. This is the normal state when joining a stream. + awaiting_keyframe: bool, +} + +/// The slices making up one picture, gathered from the input. +struct Picture<'a> { + header: SliceHeader, + nal_type: NalType, + ref_idc: u8, + is_intra: bool, + slices: Vec<&'a [u8]>, +} + +impl H264Decoder { + pub(crate) fn create(context: VideoContext, display_order: bool) -> Result { + Ok(Self { + common: DecoderCommon::new(context)?, + sps_map: HashMap::new(), + pps_map: HashMap::new(), + active_sps_id: 0, + active_pps_id: 0, + dpb: None, + reorder: ReorderBuffer::new(display_order), + reorder_depth: 0, + awaiting_keyframe: true, + }) + } + + /// Copy a decoded picture back to the host. Entirely generic. + pub(crate) fn download(&mut self, frame: &DecodedFrame) -> Result { + self.common.download(frame) + } + + /// Copy a decoded picture's planes into two caller-owned images. Generic. + pub(crate) fn copy_frame_to_planes( + &mut self, + frame: &DecodedFrame, + y_image: vk::Image, + uv_image: vk::Image, + ) -> Result<()> { + self.common.copy_frame_to_planes(frame, y_image, uv_image) + } + + /// Emit any pictures still held for reordering. Call at end of stream. + pub(crate) fn flush(&mut self) -> Result> { + Ok(self.reorder.flush()) + } + + pub(crate) fn picture_format(&self) -> Option { + self.common.session.as_ref().map(|a| a.picture_format) + } + + /// Build the H.264 decode profile info for a parsed SPS. + fn profile_for( + sps: &Sps, + ) -> Result<( + vk::VideoDecodeH264ProfileInfoKHR<'static>, + PixelFormat, + BitDepth, + )> { + let pixel_format = match sps.chroma_format_idc { + 1 => PixelFormat::Yuv420, + 3 => PixelFormat::Yuv444, + other => { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: unsupported chroma_format_idc {}", + other + ))); + } + }; + let bit_depth = match sps.bit_depth_luma_minus8 { + 0 => BitDepth::Eight, + 2 => BitDepth::Ten, + other => { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: unsupported luma bit depth {}", + other + 8 + ))); + } + }; + if sps.seq_scaling_matrix_present_flag { + return Err(PixelForgeError::InvalidInput( + "H.264 decode: streams with explicit scaling matrices are not supported" + .to_string(), + )); + } + + let std_profile = match sps.profile_idc { + 66 => ash::vk::native::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_BASELINE, + 77 => ash::vk::native::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_MAIN, + 100 => ash::vk::native::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_HIGH, + 244 => { + ash::vk::native::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_HIGH_444_PREDICTIVE + } + other => { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: unsupported profile_idc {}", + other + ))); + } + }; + + let profile = vk::VideoDecodeH264ProfileInfoKHR::default() + .std_profile_idc(std_profile) + .picture_layout(vk::VideoDecodeH264PictureLayoutFlagsKHR::PROGRESSIVE); + + Ok((profile, pixel_format, bit_depth)) + } + + /// Create (or recreate) the Vulkan session for the given parameter sets. + fn activate(&mut self, sps: &Sps, pps: &Pps) -> Result<()> { + // Reuse the session when nothing relevant changed. + if let Some(active) = &self.common.session + && self.active_sps_id == sps.sps_id + && self.active_pps_id == pps.pps_id + && active.coded_width == sps.coded_width() + && active.coded_height == sps.coded_height() + { + return Ok(()); + } + + // `profile_for` rejects scaling matrices in the SPS; a PPS can carry + // its own, and those are just as unsupported. Without this the session + // would report pic_scaling_matrix_present_flag = 0 with null scaling + // lists, and the driver would silently dequantise with a flat matrix. + if pps.pic_scaling_matrix_present_flag { + return Err(PixelForgeError::InvalidInput( + "H.264 decode: streams with explicit scaling matrices are not supported" + .to_string(), + )); + } + + let (mut profile, pixel_format, bit_depth) = Self::profile_for(sps)?; + let chroma_subsampling = match pixel_format { + PixelFormat::Yuv420 => vk::VideoChromaSubsamplingFlagsKHR::TYPE_420, + PixelFormat::Yuv444 => vk::VideoChromaSubsamplingFlagsKHR::TYPE_444, + other => { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: unsupported pixel format {:?}", + other + ))); + } + }; + let depth_flag = match bit_depth { + BitDepth::Eight => vk::VideoComponentBitDepthFlagsKHR::TYPE_8, + BitDepth::Ten => vk::VideoComponentBitDepthFlagsKHR::TYPE_10, + }; + let profile_info = vk::VideoProfileInfoKHR::default() + .video_codec_operation(vk::VideoCodecOperationFlagsKHR::DECODE_H264) + .chroma_subsampling(chroma_subsampling) + .luma_bit_depth(depth_flag) + .chroma_bit_depth(depth_flag) + .push(&mut profile); + + // Query capabilities for this exact profile. The chain borrows the + // codec caps structs, so scope it and copy the results out. + let mut h264_caps = vk::VideoDecodeH264CapabilitiesKHR::default(); + let mut decode_caps = vk::VideoDecodeCapabilitiesKHR::default(); + let ( + max_coded_extent, + picture_access_granularity, + max_dpb_slots, + cap_flags, + std_header_version, + min_bitstream_buffer_offset_alignment, + min_bitstream_buffer_size_alignment, + ) = { + let mut caps = vk::VideoCapabilitiesKHR::default() + .push(&mut h264_caps) + .push(&mut decode_caps); + query_decode_caps(&self.common.context, &profile_info, &mut caps)?; + ( + caps.max_coded_extent, + caps.picture_access_granularity, + caps.max_dpb_slots, + caps.flags, + caps.std_header_version, + caps.min_bitstream_buffer_offset_alignment, + caps.min_bitstream_buffer_size_alignment, + ) + }; + let decode_flags = decode_caps.flags; + + let coded_width = align_up(sps.coded_width(), picture_access_granularity.width.max(1)); + let coded_height = align_up(sps.coded_height(), picture_access_granularity.height.max(1)); + if coded_width > max_coded_extent.width || coded_height > max_coded_extent.height { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: stream is {}x{}, device maximum is {}x{}", + coded_width, coded_height, max_coded_extent.width, max_coded_extent.height + ))); + } + + self.common.bitstream_offset_alignment = min_bitstream_buffer_offset_alignment.max(1); + self.common.bitstream_size_alignment = min_bitstream_buffer_size_alignment.max(1); + + let coincide = + decode_flags.contains(vk::VideoDecodeCapabilityFlagsKHR::DPB_AND_OUTPUT_COINCIDE); + let use_layered_dpb = + !cap_flags.contains(vk::VideoCapabilityFlagsKHR::SEPARATE_REFERENCE_IMAGES); + + // The stream needs max_num_ref_frames references plus the current + // picture; the device caps bound how many we can actually have. + let slot_count = ((sps.max_num_ref_frames as u32 + 1).max(2)) + .min(max_dpb_slots) + .min(crate::decoder::h264::dpb::MAX_DPB_SLOTS as u32) as usize; + if (slot_count as u32) < sps.max_num_ref_frames as u32 + 1 { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: stream needs {} DPB slots, device supports {}", + sps.max_num_ref_frames as u32 + 1, + max_dpb_slots + ))); + } + + // Pick a picture format the device actually supports for decode. + let dpb_usage = if coincide { + vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR + | vk::ImageUsageFlags::VIDEO_DECODE_DST_KHR + | vk::ImageUsageFlags::TRANSFER_SRC + } else { + vk::ImageUsageFlags::VIDEO_DECODE_DPB_KHR + }; + let supported = + query_supported_video_formats(&self.common.context, &profile_info, dpb_usage)?; + let preferred = get_video_format(pixel_format, bit_depth); + let picture_format = if supported.contains(&preferred) { + preferred + } else { + *supported.first().ok_or_else(|| { + PixelForgeError::CodecNotSupported( + "H.264 decode: no supported picture format for this profile".to_string(), + ) + })? + }; + + // Hand the shared layer everything it needs to build the session; it + // owns the Vulkan objects, this module owns only the H.264 reasoning + // that produced these numbers. + let plan = SessionPlan { + coded_width, + coded_height, + picture_format, + bit_depth, + pixel_format, + slot_count, + coincide, + use_layered_dpb, + dpb_usage, + }; + self.common.create_session( + &plan, + &profile_info, + &std_header_version, + |common, session| Self::build_session_params(common, session, sps, pps), + )?; + + self.active_sps_id = sps.sps_id; + self.active_pps_id = pps.pps_id; + self.dpb = Some(DecodeDpb::new(slot_count)); + + debug!( + "H.264 decode session: {}x{} {:?}, {} DPB slots, layered={}, coincide={}", + coded_width, coded_height, picture_format, slot_count, use_layered_dpb, coincide + ); + Ok(()) + } + + /// Translate the parsed SPS/PPS into StdVideo structs and create session + /// parameters. + fn build_session_params( + common: &DecoderCommon, + session: vk::VideoSessionKHR, + sps: &Sps, + pps: &Pps, + ) -> Result { + let mut sps_flags: ash::vk::native::StdVideoH264SpsFlags = unsafe { std::mem::zeroed() }; + sps_flags.set_constraint_set0_flag((sps.constraint_set_flags >> 7) as u32 & 1); + sps_flags.set_constraint_set1_flag((sps.constraint_set_flags >> 6) as u32 & 1); + sps_flags.set_constraint_set2_flag((sps.constraint_set_flags >> 5) as u32 & 1); + sps_flags.set_constraint_set3_flag((sps.constraint_set_flags >> 4) as u32 & 1); + sps_flags.set_constraint_set4_flag((sps.constraint_set_flags >> 3) as u32 & 1); + sps_flags.set_constraint_set5_flag((sps.constraint_set_flags >> 2) as u32 & 1); + sps_flags.set_direct_8x8_inference_flag(sps.direct_8x8_inference_flag as u32); + sps_flags.set_mb_adaptive_frame_field_flag(sps.mb_adaptive_frame_field_flag as u32); + sps_flags.set_frame_mbs_only_flag(sps.frame_mbs_only_flag as u32); + sps_flags.set_delta_pic_order_always_zero_flag(sps.delta_pic_order_always_zero_flag as u32); + sps_flags.set_separate_colour_plane_flag(sps.separate_colour_plane_flag as u32); + sps_flags.set_gaps_in_frame_num_value_allowed_flag( + sps.gaps_in_frame_num_value_allowed_flag as u32, + ); + sps_flags.set_qpprime_y_zero_transform_bypass_flag( + sps.qpprime_y_zero_transform_bypass_flag as u32, + ); + sps_flags.set_frame_cropping_flag(sps.frame_cropping_flag as u32); + sps_flags.set_seq_scaling_matrix_present_flag(0); + sps_flags.set_vui_parameters_present_flag(0); + + let std_sps = ash::vk::native::StdVideoH264SequenceParameterSet { + flags: sps_flags, + profile_idc: std_profile_idc(sps.profile_idc)?, + level_idc: std_level_idc(sps.level_idc), + chroma_format_idc: std_chroma_format_idc(sps.chroma_format_idc)?, + seq_parameter_set_id: sps.sps_id, + bit_depth_luma_minus8: sps.bit_depth_luma_minus8, + bit_depth_chroma_minus8: sps.bit_depth_chroma_minus8, + log2_max_frame_num_minus4: sps.log2_max_frame_num_minus4, + pic_order_cnt_type: sps.pic_order_cnt_type as u32, + offset_for_non_ref_pic: sps.offset_for_non_ref_pic, + offset_for_top_to_bottom_field: sps.offset_for_top_to_bottom_field, + log2_max_pic_order_cnt_lsb_minus4: sps.log2_max_pic_order_cnt_lsb_minus4, + num_ref_frames_in_pic_order_cnt_cycle: sps.offsets_for_ref_frame.len() as u8, + max_num_ref_frames: sps.max_num_ref_frames, + reserved1: 0, + pic_width_in_mbs_minus1: sps.pic_width_in_mbs_minus1, + pic_height_in_map_units_minus1: sps.pic_height_in_map_units_minus1, + frame_crop_left_offset: sps.frame_crop_left_offset, + frame_crop_right_offset: sps.frame_crop_right_offset, + frame_crop_top_offset: sps.frame_crop_top_offset, + frame_crop_bottom_offset: sps.frame_crop_bottom_offset, + reserved2: 0, + pOffsetForRefFrame: if sps.offsets_for_ref_frame.is_empty() { + std::ptr::null() + } else { + sps.offsets_for_ref_frame.as_ptr() + }, + pScalingLists: std::ptr::null(), + pSequenceParameterSetVui: std::ptr::null(), + }; + + let mut pps_flags: ash::vk::native::StdVideoH264PpsFlags = unsafe { std::mem::zeroed() }; + pps_flags.set_transform_8x8_mode_flag(pps.transform_8x8_mode_flag as u32); + pps_flags.set_redundant_pic_cnt_present_flag(pps.redundant_pic_cnt_present_flag as u32); + pps_flags.set_constrained_intra_pred_flag(pps.constrained_intra_pred_flag as u32); + pps_flags.set_deblocking_filter_control_present_flag( + pps.deblocking_filter_control_present_flag as u32, + ); + pps_flags.set_weighted_pred_flag(pps.weighted_pred_flag as u32); + pps_flags.set_bottom_field_pic_order_in_frame_present_flag( + pps.bottom_field_pic_order_in_frame_present_flag as u32, + ); + pps_flags.set_entropy_coding_mode_flag(pps.entropy_coding_mode_flag as u32); + pps_flags.set_pic_scaling_matrix_present_flag(0); + + let std_pps = ash::vk::native::StdVideoH264PictureParameterSet { + flags: pps_flags, + seq_parameter_set_id: pps.sps_id, + pic_parameter_set_id: pps.pps_id, + num_ref_idx_l0_default_active_minus1: pps.num_ref_idx_l0_default_active_minus1, + num_ref_idx_l1_default_active_minus1: pps.num_ref_idx_l1_default_active_minus1, + weighted_bipred_idc: pps.weighted_bipred_idc as u32, + pic_init_qp_minus26: pps.pic_init_qp_minus26, + pic_init_qs_minus26: pps.pic_init_qs_minus26, + chroma_qp_index_offset: pps.chroma_qp_index_offset, + second_chroma_qp_index_offset: pps.second_chroma_qp_index_offset, + pScalingLists: std::ptr::null(), + }; + + let sps_array = [std_sps]; + let pps_array = [std_pps]; + let add_info = vk::VideoDecodeH264SessionParametersAddInfoKHR::default() + .std_sp_ss(&sps_array) + .std_pp_ss(&pps_array); + let mut h264_create = vk::VideoDecodeH264SessionParametersCreateInfoKHR::default() + .max_std_sps_count(1) + .max_std_pps_count(1) + .parameters_add_info(&add_info); + + let create_info = vk::VideoSessionParametersCreateInfoKHR::default() + .video_session(session) + .push(&mut h264_create); + + unsafe { + common + .video_queue_fn + .create_video_session_parameters(&create_info, None) + } + .map_err(|e| { + PixelForgeError::SessionParametersCreation(format!( + "H.264 decode session parameters: {:?}", + e + )) + }) + } + + /// Split `data` into pictures, absorbing any parameter sets encountered. + /// Group NALs into pictures. Returns the pictures plus, if any slice was + /// skipped for missing parameter sets, the reason a keyframe is needed. + fn split_pictures<'a>(&mut self, data: &'a [u8]) -> Result<(Vec>, Option)> { + // Parameter sets are absorbed into self; slice grouping is delegated to + // the pure `group_slices` so it can be tested without a device. + let mut pictures: Vec> = Vec::new(); + let mut awaiting: Option = None; + + for nal in iter_nal_units(data) { + match nal.nal_type { + NalType::Sps => { + let sps = parser::parse_sps(nal.payload())?; + debug!( + "H.264 SPS id={} {}x{} profile={}", + sps.sps_id, + sps.coded_width(), + sps.coded_height(), + sps.profile_idc + ); + self.sps_map.insert(sps.sps_id, sps); + } + NalType::Pps => { + let sps_map = &self.sps_map; + let pps = parser::parse_pps(nal.payload(), |id| { + sps_map.get(&id).map(|s| s.chroma_format_idc) + })?; + self.pps_map.insert(pps.pps_id, pps); + } + t if t.is_slice() => { + match group_slices(&mut pictures, &nal, &self.sps_map, &self.pps_map) { + Ok(()) => {} + // Missing parameter sets: skip the slice, remember why. + Err(PixelForgeError::NeedsKeyframe(reason)) => { + awaiting.get_or_insert(reason); + } + Err(e) => return Err(e), + } + } + // SEI, AUD, and everything else is not needed by the driver. + _ => {} + } + } + + Ok((pictures, awaiting)) + } + + pub(crate) fn decode(&mut self, data: &[u8], pts: u64) -> Result> { + // What is missing when a picture cannot be decoded yet; set only while + // no frame has been produced, so a keyframe later in the same buffer + // still yields output. Seeded with slices skipped during splitting for + // missing parameter sets. + let (pictures, mut awaiting) = self.split_pictures(data)?; + let mut frames = Vec::with_capacity(pictures.len()); + + for picture in pictures { + let is_idr = picture.nal_type == NalType::IdrSlice; + + // A non-IDR picture before the first keyframe references reference + // pictures and POC state that do not exist yet. Skip it and ask for + // a keyframe rather than decoding garbage. + if self.awaiting_keyframe && !is_idr { + awaiting.get_or_insert_with(|| { + "no keyframe decoded yet; non-IDR picture cannot be decoded".to_string() + }); + continue; + } + + // Resolve the parameter sets this picture uses. Missing sets mean we + // joined before they were sent — also a keyframe-recovery case. + let pps = match self.pps_map.get(&picture.header.pps_id).cloned() { + Some(pps) => pps, + None => { + awaiting.get_or_insert_with(|| { + format!("PPS {} not yet received", picture.header.pps_id) + }); + continue; + } + }; + let sps = match self.sps_map.get(&pps.sps_id).cloned() { + Some(sps) => sps, + None => { + awaiting.get_or_insert_with(|| format!("SPS {} not yet received", pps.sps_id)); + continue; + } + }; + + // (Re)build the session if the stream's geometry or ids changed. + self.activate(&sps, &pps)?; + // Reorder depth comes from the stream (VUI); without it, bound by + // the reference count, which is never smaller than the reorder + // depth in practice. + self.reorder_depth = sps + .max_num_reorder_frames + .map(|v| v as usize) + .unwrap_or(sps.max_num_ref_frames as usize); + + if let Some(frame) = self.decode_picture(&picture, &sps, pts)? { + // A decoded IDR re-establishes a valid decode point. + if is_idr { + self.awaiting_keyframe = false; + } + let depth_reorder = self.reorder_depth; + let ready = self.reorder.push(&self.common, &frame, depth_reorder)?; + frames.extend(ready); + } + } + + // Only surface the keyframe request when nothing was produced: if a + // keyframe arrived later in the same buffer, its frames take priority. + if frames.is_empty() + && let Some(reason) = awaiting + { + return Err(PixelForgeError::NeedsKeyframe(format!( + "H.264 decode: {}", + reason + ))); + } + + Ok(frames) + } + + /// Record and submit the decode of a single picture, and wait for it. + fn decode_picture( + &mut self, + picture: &Picture, + sps: &Sps, + pts: u64, + ) -> Result> { + // --- Host-side bookkeeping: POC, slot, reference set --- + let state = { + let dpb = self.dpb.as_mut().expect("activated above"); + dpb.begin_picture( + picture.nal_type, + picture.ref_idc, + &picture.header, + sps, + picture.is_intra, + )? + }; + let slot = self.dpb.as_mut().expect("active").allocate_slot()?; + + // --- Pack the slice data into the staging buffer --- + let (buffer_range, slice_offsets) = self.stage_slices(picture, sps)?; + + // --- Record and submit --- + self.common.begin_decode_commands()?; + self.common.record_barriers(slot); + self.record_decode(picture, &state, slot, buffer_range, &slice_offsets)?; + self.common.submit_decode()?; + + // --- Publish the frame, then retire references --- + let active = self.common.session.as_mut().expect("active"); + active.dpb_slot_active[slot as usize] = true; + + let (image, image_view, layout) = if active.coincide { + let image = if active.use_layered_dpb { + active.dpb_images[0] + } else { + active.dpb_images[slot as usize] + }; + ( + image, + active.dpb_views[slot as usize], + vk::ImageLayout::VIDEO_DECODE_DPB_KHR, + ) + } else { + let (image, _, view) = active + .output_image + .expect("non-coincide implies output image"); + (image, view, vk::ImageLayout::VIDEO_DECODE_DST_KHR) + }; + + let (width, height) = sps.display_dimensions(); + let frame = DecodedFrame { + image, + image_view, + layout, + width, + height, + coded_width: active.coded_width, + coded_height: active.coded_height, + pts, + poc: state.poc, + is_idr: state.is_idr, + }; + + self.dpb + .as_mut() + .expect("active") + .end_picture(slot, &state, sps); + + Ok(Some(frame)) + } + + /// Copy the picture's slice NALs into the staging buffer. + /// + /// Returns the aligned buffer range to hand to the driver and the offset of + /// each slice within it. + /// Stage this picture's slices for the driver. + /// + /// The copying is generic; only the profile (needed to size the buffer) is + /// H.264's business. + fn stage_slices(&mut self, picture: &Picture, sps: &Sps) -> Result<(u64, Vec)> { + let (mut profile, pixel_format, bit_depth) = Self::profile_for(sps)?; + let chroma_subsampling = match pixel_format { + PixelFormat::Yuv420 => vk::VideoChromaSubsamplingFlagsKHR::TYPE_420, + _ => vk::VideoChromaSubsamplingFlagsKHR::TYPE_444, + }; + let depth_flag = match bit_depth { + BitDepth::Eight => vk::VideoComponentBitDepthFlagsKHR::TYPE_8, + BitDepth::Ten => vk::VideoComponentBitDepthFlagsKHR::TYPE_10, + }; + let profile_info = vk::VideoProfileInfoKHR::default() + .video_codec_operation(vk::VideoCodecOperationFlagsKHR::DECODE_H264) + .chroma_subsampling(chroma_subsampling) + .luma_bit_depth(depth_flag) + .chroma_bit_depth(depth_flag) + .push(&mut profile); + + self.common.stage_slices(&picture.slices, &profile_info) + } + + /// Transition the DPB slots this decode touches into DPB layout, and the + /// output image (if distinct) into decode-DST layout. + /// Record `vkCmdBeginVideoCoding` / `vkCmdDecodeVideo` / `vkCmdEndVideoCoding`. + fn record_decode( + &self, + picture: &Picture, + state: &PictureState, + slot: u8, + buffer_range: u64, + slice_offsets: &[u32], + ) -> Result<()> { + let active = self.common.session.as_ref().expect("active"); + + let extent = vk::Extent2D { + width: active.coded_width, + height: active.coded_height, + }; + + // --- Reference slots --- + // Every reference the driver may use, plus the slot we reconstruct into. + let refs = self.dpb.as_ref().expect("active").references().to_vec(); + + let mut ref_std_infos: Vec = Vec::new(); + for r in &refs { + let mut flags: ash::vk::native::StdVideoDecodeH264ReferenceInfoFlags = + unsafe { std::mem::zeroed() }; + flags.set_top_field_flag(0); + flags.set_bottom_field_flag(0); + flags.set_used_for_long_term_reference(r.long_term as u32); + flags.set_is_non_existing(0); + // For a long-term reference this field carries LongTermFrameIdx + // rather than frame_num; that is how the driver identifies it when + // building the reference lists. + let frame_num = if r.long_term { + r.long_term_frame_idx as u16 + } else { + r.frame_num + }; + ref_std_infos.push(ash::vk::native::StdVideoDecodeH264ReferenceInfo { + flags, + FrameNum: frame_num, + reserved: 0, + PicOrderCnt: r.poc, + }); + } + + // Setup (reconstruction) slot info for the current picture. + let mut setup_flags: ash::vk::native::StdVideoDecodeH264ReferenceInfoFlags = + unsafe { std::mem::zeroed() }; + setup_flags.set_top_field_flag(0); + setup_flags.set_bottom_field_flag(0); + setup_flags.set_used_for_long_term_reference(0); + setup_flags.set_is_non_existing(0); + let setup_std_info = ash::vk::native::StdVideoDecodeH264ReferenceInfo { + flags: setup_flags, + FrameNum: state.frame_num, + reserved: 0, + PicOrderCnt: [state.poc, state.poc], + }; + + // Picture resources. These must outlive the command recording. + let dpb_resource = |slot: u8| -> vk::VideoPictureResourceInfoKHR<'_> { + vk::VideoPictureResourceInfoKHR::default() + .coded_offset(vk::Offset2D { x: 0, y: 0 }) + .coded_extent(extent) + .base_array_layer(0) + .image_view_binding(active.dpb_views[slot as usize]) + }; + + let ref_resources: Vec = + refs.iter().map(|r| dpb_resource(r.slot)).collect(); + let setup_resource = dpb_resource(slot); + + let mut ref_h264_infos: Vec = ref_std_infos + .iter() + .map(|info| vk::VideoDecodeH264DpbSlotInfoKHR::default().std_reference_info(info)) + .collect(); + let mut setup_h264_info = + vk::VideoDecodeH264DpbSlotInfoKHR::default().std_reference_info(&setup_std_info); + + // Slots passed to begin_video_coding: all active references, plus the + // slot we reconstruct into. The latter is not active yet -- it only + // becomes active via `setup_reference_slot` in the decode info -- and + // a non-negative slot_index here must name an already-active slot + // (VUID-vkCmdBeginVideoCodingKHR-slotIndex-07239). So it is listed + // with slot_index = -1, which still binds its picture resource. + let mut begin_slots: Vec = Vec::new(); + for ((r, resource), h264_info) in refs + .iter() + .zip(ref_resources.iter()) + .zip(ref_h264_infos.iter_mut()) + { + begin_slots.push( + vk::VideoReferenceSlotInfoKHR::default() + .slot_index(r.slot as i32) + .picture_resource(resource) + .push(h264_info), + ); + } + begin_slots.push( + vk::VideoReferenceSlotInfoKHR::default() + .slot_index(-1) + .picture_resource(&setup_resource), + ); + + let begin_info = vk::VideoBeginCodingInfoKHR::default() + .video_session(active.session) + .video_session_parameters(active.session_params) + .reference_slots(&begin_slots); + + let device = self.common.context.device(); + unsafe { + self.common + .video_queue_fn + .cmd_begin_video_coding(self.common.command_buffer, &begin_info); + } + + // On the first use of the session, all DPB slots must be reset. + if !active.dpb_slot_active.iter().any(|&a| a) { + let control = vk::VideoCodingControlInfoKHR::default() + .flags(vk::VideoCodingControlFlagsKHR::RESET); + unsafe { + self.common + .video_queue_fn + .cmd_control_video_coding(self.common.command_buffer, &control); + } + } + + // --- Picture info --- + let mut pic_flags: ash::vk::native::StdVideoDecodeH264PictureInfoFlags = + unsafe { std::mem::zeroed() }; + pic_flags.set_field_pic_flag(0); + pic_flags.set_is_intra(state.is_intra as u32); + pic_flags.set_IdrPicFlag(state.is_idr as u32); + pic_flags.set_bottom_field_flag(0); + pic_flags.set_is_reference(state.is_reference as u32); + pic_flags.set_complementary_field_pair(0); + + let std_pic_info = ash::vk::native::StdVideoDecodeH264PictureInfo { + flags: pic_flags, + seq_parameter_set_id: self.active_sps_id, + pic_parameter_set_id: picture.header.pps_id, + reserved1: 0, + reserved2: 0, + frame_num: state.frame_num, + idr_pic_id: state.idr_pic_id, + PicOrderCnt: [state.poc, state.poc], + }; + + let mut h264_picture_info = vk::VideoDecodeH264PictureInfoKHR::default() + .std_picture_info(&std_pic_info) + .slice_offsets(slice_offsets); + + // The decode destination: the DPB slot itself when the implementation + // supports coincide, otherwise the separate output image. + let output_resource = match active.output_image { + None => setup_resource, + Some((_, _, view)) => vk::VideoPictureResourceInfoKHR::default() + .coded_offset(vk::Offset2D { x: 0, y: 0 }) + .coded_extent(extent) + .base_array_layer(0) + .image_view_binding(view), + }; + + // Only reference pictures need a setup slot; disposable pictures still + // decode into a DPB resource but are not retained. + let setup_slot = vk::VideoReferenceSlotInfoKHR::default() + .slot_index(slot as i32) + .picture_resource(&setup_resource) + .push(&mut setup_h264_info); + + // Reference slots for the decode itself (the current slot is excluded). + let mut decode_ref_h264_infos: Vec = ref_std_infos + .iter() + .map(|info| vk::VideoDecodeH264DpbSlotInfoKHR::default().std_reference_info(info)) + .collect(); + let mut decode_slots: Vec = Vec::new(); + for ((r, resource), h264_info) in refs + .iter() + .zip(ref_resources.iter()) + .zip(decode_ref_h264_infos.iter_mut()) + { + decode_slots.push( + vk::VideoReferenceSlotInfoKHR::default() + .slot_index(r.slot as i32) + .picture_resource(resource) + .push(h264_info), + ); + } + + let decode_info = vk::VideoDecodeInfoKHR::default() + .src_buffer(self.common.bitstream_buffer) + .src_buffer_offset(0) + .src_buffer_range(buffer_range) + .dst_picture_resource(output_resource) + .setup_reference_slot(&setup_slot) + .reference_slots(&decode_slots) + .push(&mut h264_picture_info); + + unsafe { + self.common + .video_decode_fn + .cmd_decode_video(self.common.command_buffer, &decode_info); + self.common.video_queue_fn.cmd_end_video_coding( + self.common.command_buffer, + &vk::VideoEndCodingInfoKHR::default(), + ); + } + let _ = device; + Ok(()) + } +} + +impl Drop for H264Decoder { + fn drop(&mut self) { + // The reorder pool's images must be freed while `common` (and its + // device) is still alive; `common`'s own Drop runs afterward. + self.reorder.destroy(&self.common); + } +} + +/// Append a slice NAL to the picture in progress, or begin a new picture. +/// +/// Free function (rather than a method) so the grouping logic — which is pure +/// and easy to get wrong for multi-slice streams — can be unit tested without a +/// Vulkan device. +fn group_slices<'a>( + pictures: &mut Vec>, + nal: &NalUnit<'a>, + sps_map: &HashMap, + pps_map: &HashMap, +) -> Result<()> { + // Peek the header to find the picture this slice belongs to. + // Peek just far enough to find which PPS (and hence SPS) applies. + let mut probe = crate::decoder::bitreader::BitReader::new(nal.payload()); + let _first_mb_in_slice = probe.ue()?; + let _slice_type = probe.ue()?; + let pps_id = probe.ue()? as u8; + + // Missing parameter sets: we joined the stream before they were sent. Not a + // fault — signal that a keyframe (which carries fresh sets) is needed. + let pps = pps_map.get(&pps_id).ok_or_else(|| { + PixelForgeError::NeedsKeyframe(format!("PPS {} not yet received", pps_id)) + })?; + let sps = sps_map.get(&pps.sps_id).ok_or_else(|| { + PixelForgeError::NeedsKeyframe(format!("SPS {} not yet received", pps.sps_id)) + })?; + + let header = parser::parse_slice_header(nal, sps, pps)?; + let is_intra = matches!(header.slice_type, SliceType::I | SliceType::Si); + + // Picture boundary detection (clause 7.4.1.2.4, frame-picture subset). + // + // `first_mb_in_slice == 0` marks the first slice of a picture and is + // sufficient for conforming streams; the syntax comparisons additionally + // catch a picture boundary that a truncated or spliced stream would + // otherwise hide (e.g. a dropped first slice). + let starts_new = match pictures.last() { + None => true, + Some(prev) => { + header.first_mb_in_slice == 0 + || prev.header.frame_num != header.frame_num + || prev.header.pps_id != header.pps_id + || prev.nal_type != nal.nal_type + || prev.header.pic_order_cnt_lsb != header.pic_order_cnt_lsb + || prev.header.idr_pic_id != header.idr_pic_id + } + }; + + if starts_new { + pictures.push(Picture { + header, + nal_type: nal.nal_type, + ref_idc: nal.ref_idc, + is_intra, + slices: vec![nal.data], + }); + } else { + let picture = pictures.last_mut().expect("checked above"); + // A picture is intra only if *every* slice is intra. + picture.is_intra &= is_intra; + picture.slices.push(nal.data); + } + Ok(()) +} + +fn std_profile_idc(profile_idc: u8) -> Result { + Ok(match profile_idc { + 66 => ash::vk::native::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_BASELINE, + 77 => ash::vk::native::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_MAIN, + 100 => ash::vk::native::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_HIGH, + 244 => { + ash::vk::native::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_HIGH_444_PREDICTIVE + } + other => { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: unsupported profile_idc {}", + other + ))); + } + }) +} + +fn std_chroma_format_idc(idc: u8) -> Result { + Ok(match idc { + 0 => { + ash::vk::native::StdVideoH264ChromaFormatIdc_STD_VIDEO_H264_CHROMA_FORMAT_IDC_MONOCHROME + } + 1 => ash::vk::native::StdVideoH264ChromaFormatIdc_STD_VIDEO_H264_CHROMA_FORMAT_IDC_420, + 2 => ash::vk::native::StdVideoH264ChromaFormatIdc_STD_VIDEO_H264_CHROMA_FORMAT_IDC_422, + 3 => ash::vk::native::StdVideoH264ChromaFormatIdc_STD_VIDEO_H264_CHROMA_FORMAT_IDC_444, + other => { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 decode: invalid chroma_format_idc {}", + other + ))); + } + }) +} + +/// Map `level_idc` (10 * level) onto the StdVideo enum. +fn std_level_idc(level_idc: u8) -> ash::vk::native::StdVideoH264LevelIdc { + use ash::vk::native::*; + match level_idc { + 10 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_1_0, + 11 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_1_1, + 12 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_1_2, + 13 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_1_3, + 20 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_2_0, + 21 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_2_1, + 22 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_2_2, + 30 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_0, + 31 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_1, + 32 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_3_2, + 40 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_4_0, + 41 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_4_1, + 42 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_4_2, + 50 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_5_0, + 51 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_5_1, + 52 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_5_2, + 60 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_0, + 61 => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_1, + _ => StdVideoH264LevelIdc_STD_VIDEO_H264_LEVEL_IDC_6_2, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Group the slices of a real multi-slice stream (4 slices per picture, + /// generated by `x264 --slices 4`). Exercises `group_slices` without a GPU. + #[test] + fn groups_multislice_pictures() { + let data: &[u8] = include_bytes!("../../../tests/data/multislice.264"); + + let mut sps_map = HashMap::new(); + let mut pps_map = HashMap::new(); + let mut pictures: Vec = Vec::new(); + + for nal in parser::iter_nal_units(data) { + match nal.nal_type { + NalType::Sps => { + let sps = parser::parse_sps(nal.payload()).unwrap(); + sps_map.insert(sps.sps_id, sps); + } + NalType::Pps => { + let pps = parser::parse_pps(nal.payload(), |id| { + sps_map.get(&id).map(|s: &Sps| s.chroma_format_idc) + }) + .unwrap(); + pps_map.insert(pps.pps_id, pps); + } + t if t.is_slice() => { + group_slices(&mut pictures, &nal, &sps_map, &pps_map).unwrap(); + } + _ => {} + } + } + + // 120 slice NALs must group into 30 pictures of 4 slices each. + assert_eq!(pictures.len(), 30, "expected 30 pictures"); + for (i, picture) in pictures.iter().enumerate() { + assert_eq!(picture.slices.len(), 4, "picture {i} should have 4 slices"); + } + + // The first picture is the IDR and is intra-coded. + assert_eq!(pictures[0].nal_type, NalType::IdrSlice); + assert!(pictures[0].is_intra, "IDR must be intra"); + assert_ne!(pictures[0].ref_idc, 0, "IDR must be a reference"); + + // frame_num must be constant within a picture and advance between them. + assert_eq!(pictures[0].header.frame_num, 0); + assert!( + pictures[1].header.frame_num > 0, + "frame_num must advance after the IDR" + ); + } +} diff --git a/src/decoder/mod.rs b/src/decoder/mod.rs new file mode 100644 index 0000000..8fa0181 --- /dev/null +++ b/src/decoder/mod.rs @@ -0,0 +1,306 @@ +//! Hardware-accelerated video decoding using Vulkan Video. +//! +//! The decoder consumes a raw byte stream (Annex B for H.264: start-code +//! delimited NAL units) and produces decoded frames as GPU images +//! (`vk::Image`), optionally with CPU readback. +//! +//! # Design +//! +//! - **Stream-driven**: the Vulkan video session is created lazily from the +//! stream's own parameter sets, so no dimensions or profile need to be +//! configured up front. Mid-stream resolution changes recreate the session +//! transparently. +//! - **Display order by default**: frames come out in presentation order (see +//! [`OutputOrder`]); drain the reorder buffer with [`Decoder::flush`] at end +//! of stream. Streams without B-frames add no latency. Switch to +//! [`OutputOrder::Decode`] for the lowest-latency decode-order output. +//! - **Zero-copy output**: [`DecodedFrame::image`] is a decoder-owned GPU +//! image. See [`DecodedFrame`] for validity rules. +//! +//! # Limitations (H.264) +//! +//! - Progressive streams only (no interlaced/field coding). +//! - Streams using explicit scaling matrices are rejected. +//! +//! Reference management is complete (IDR, sliding-window, and MMCO), so any +//! stream this crate's encoder produces — and typical output from x264, NVENC +//! and friends, including B-pyramid — decodes correctly. + +pub(crate) mod bitreader; +pub(crate) mod codec; +/// H.264 decoding. +pub mod h264; + +use crate::encoder::{BitDepth, Codec, PixelFormat}; +use crate::error::{PixelForgeError, Result}; +use crate::vulkan::VideoContext; +use ash::vk; + +/// The order in which [`Decoder::decode`] returns frames. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputOrder { + /// Presentation order (default). Frames come out sorted by picture order + /// count, matching how a player would show them. The decoder buffers a + /// bounded number of frames (the stream's `max_num_reorder_frames`), so + /// call [`Decoder::flush`] at end of stream to drain the rest. For streams + /// without B-frames this adds no latency and behaves like [`Self::Decode`]. + Display, + /// Decode order: frames are returned the moment their GPU work completes, + /// the lowest-latency option. With B-frames the caller must reorder by + /// [`DecodedFrame::poc`] if presentation order matters. + Decode, +} + +/// Configuration for creating a [`Decoder`]. +#[derive(Debug, Clone)] +pub struct DecodeConfig { + /// The codec to decode. + pub codec: Codec, + /// The order frames are returned in. Defaults to [`OutputOrder::Display`]. + pub output_order: OutputOrder, +} + +impl DecodeConfig { + /// Create an H.264 decode configuration (display-order output). + pub fn h264() -> Self { + Self { + codec: Codec::H264, + output_order: OutputOrder::Display, + } + } + + /// Return frames in decode order for lowest latency, rather than sorting + /// them into presentation order. See [`OutputOrder`]. + pub fn with_decode_order(mut self) -> Self { + self.output_order = OutputOrder::Decode; + self + } +} + +/// A decoded frame residing in GPU memory. +/// +/// # Validity +/// +/// [`image`](Self::image) is a decoder-owned GPU image. It remains valid and +/// unmodified until **the next call to [`Decoder::decode`] or +/// [`Decoder::flush`]**, at which point the decoder may reuse it. For zero-copy +/// consumption, use the image (sample it, copy it, encode from it) before +/// feeding more data to the decoder; for retention, use [`Decoder::download`] +/// or copy it to an image you own. +#[derive(Debug, Clone)] +pub struct DecodedFrame { + /// The decoded picture on the GPU. + /// + /// The image format is the decoder's picture format (see + /// [`Decoder::picture_format`]), typically NV12 + /// (`G8_B8R8_2PLANE_420_UNORM`) or P010 for 10-bit streams. The image is + /// in `VIDEO_DECODE_DPB_KHR` or `VIDEO_DECODE_DST_KHR` layout (see + /// [`layout`](Self::layout)) and sized to the coded dimensions; only the + /// `width` x `height` top-left region contains the visible picture. + pub image: vk::Image, + /// An image view over the whole decoded picture. + pub image_view: vk::ImageView, + /// Current layout of `image`. + pub layout: vk::ImageLayout, + /// Visible (cropped) width in pixels. + pub width: u32, + /// Visible (cropped) height in pixels. + pub height: u32, + /// Coded width in pixels (image width). + pub coded_width: u32, + /// Coded height in pixels (image height). + pub coded_height: u32, + /// Presentation timestamp, passed through from [`Decoder::decode`]. + pub pts: u64, + /// Picture order count (presentation order within a coded video sequence). + /// In [`OutputOrder::Decode`] mode, sort by this for presentation order. + pub poc: i32, + /// Whether this frame is an IDR (stream random-access point). + pub is_idr: bool, +} + +/// Decoded frame data downloaded to the CPU. +#[derive(Debug, Clone)] +pub struct DecodedFrameData { + /// Luma plane, `y_stride * height` bytes. + pub y: Vec, + /// Interleaved chroma plane (semi-planar: UV for NV12/P010). + pub uv: Vec, + /// Row stride of the luma plane in bytes. + pub y_stride: usize, + /// Row stride of the chroma plane in bytes. + pub uv_stride: usize, + /// Visible width in pixels. + pub width: u32, + /// Visible height in pixels. + pub height: u32, + /// Bit depth of the samples (8 => NV12, 10 => P010). + pub bit_depth: BitDepth, + /// Chroma subsampling. + pub pixel_format: PixelFormat, +} + +/// Split an Annex B stream into access units (one coded picture each). +/// +/// [`Decoder::decode`] treats the end of its input as a picture boundary, so +/// callers feeding a whole file must split it first. Feeding one access unit +/// per call is also the lowest-latency usage. +/// +/// A new access unit begins at each VCL NAL whose `first_mb_in_slice` is 0. +/// Parameter sets, SEI and access unit delimiters are attached to the picture +/// that follows them. +/// +/// ```no_run +/// # use pixelforge::decoder::{access_units, Decoder}; +/// # fn run(decoder: &mut Decoder, stream: &[u8]) -> pixelforge::error::Result<()> { +/// for (i, au) in access_units(stream).enumerate() { +/// for frame in decoder.decode(au, i as u64)? { +/// // ... use frame before the next decode() call ... +/// } +/// } +/// # Ok(()) +/// # } +/// ``` +pub fn access_units(stream: &[u8]) -> impl Iterator { + let mut starts: Vec = Vec::new(); + let mut pending_start: Option = None; + + for nal in h264::parser::iter_nal_units_with_offsets(stream) { + let (offset, nal) = nal; + if nal.nal_type.is_slice() { + // first_mb_in_slice is the leading ue(v) of the slice header; it is + // zero — encoded as a leading `1` bit — exactly at a picture start. + let first_mb_is_zero = nal.payload().first().is_some_and(|b| b & 0x80 != 0); + if first_mb_is_zero { + starts.push(pending_start.take().unwrap_or(offset)); + } + pending_start = None; + } else if pending_start.is_none() { + // Parameter sets / SEI / AUD belong to the picture they precede. + pending_start = Some(offset); + } + } + + let ends: Vec = starts + .iter() + .skip(1) + .copied() + .chain(std::iter::once(stream.len())) + .collect(); + + starts + .into_iter() + .zip(ends) + .map(move |(start, end)| &stream[start..end]) + .collect::>() + .into_iter() +} + +/// The codec-erased operations every codec decoder exposes. +trait DecoderApi: Send { + fn decode(&mut self, data: &[u8], pts: u64) -> Result>; + fn flush(&mut self) -> Result>; + fn download(&mut self, frame: &DecodedFrame) -> Result; + fn copy_frame_to_planes( + &mut self, + frame: &DecodedFrame, + y_image: vk::Image, + uv_image: vk::Image, + ) -> Result<()>; + fn picture_format(&self) -> Option; +} + +/// Video decoder supporting multiple codecs. +/// +/// Constructed via [`Decoder::new`], which selects the codec from the config. +/// Mirrors [`Encoder`](crate::encoder::Encoder): all codec decoders share the +/// same generic driving flow and are held behind a single boxed pointer. +pub struct Decoder(Box); + +impl Decoder { + /// Create a new decoder for the codec named in `config`. + /// + /// Requires a [`VideoContext`] whose device has a video decode queue and + /// supports the codec (see + /// [`VideoContextBuilder::require_decode`](crate::vulkan::VideoContextBuilder::require_decode) + /// and [`VideoContext::supports_decode`]). + pub fn new(context: VideoContext, config: DecodeConfig) -> Result { + if !context.supports_decode(config.codec) { + return Err(PixelForgeError::CodecNotSupported(format!( + "{:?} decode is not supported by the selected device", + config.codec + ))); + } + let display_order = config.output_order == OutputOrder::Display; + let inner: Box = match config.codec { + Codec::H264 => Box::new(h264::H264Decoder::create(context, display_order)?), + other => { + return Err(PixelForgeError::CodecNotSupported(format!( + "{:?} decoding is not implemented yet", + other + ))); + } + }; + Ok(Decoder(inner)) + } + + /// Decode a chunk of the raw byte stream. + /// + /// `data` may contain any number of *complete* access units (Annex B + /// start codes for H.264): parameter sets, one access unit, or several. + /// The end of `data` is treated as an access-unit boundary, so a picture + /// must not be split across calls. For lowest latency, feed exactly one + /// access unit per call; the picture is decoded immediately and returned + /// from the same call. + /// + /// `pts` is attached to every frame produced by this call. + /// + /// Returns decoded frames in the configured [`OutputOrder`] (display order + /// by default). In display order the count returned per call varies — a + /// picture may be held back until later ones are decoded — so drain the + /// remainder with [`flush`](Self::flush) at end of stream. In decode order + /// it is usually zero or one frame per access unit fed. + pub fn decode(&mut self, data: &[u8], pts: u64) -> Result> { + self.0.decode(data, pts) + } + + /// Return any frames still held for display-order reordering. + /// + /// Call once after the last [`decode`](Self::decode) to emit the tail of + /// the stream. Frames come back in presentation order. Harmless (returns + /// empty) in decode-order mode. + pub fn flush(&mut self) -> Result> { + self.0.flush() + } + + /// Download a decoded frame to the CPU as semi-planar YUV (NV12 / P010). + pub fn download(&mut self, frame: &DecodedFrame) -> Result { + self.0.download(frame) + } + + /// Copy a decoded frame's planes into two caller-owned GPU images: luma into + /// `y_image`, interleaved chroma into `uv_image`. + /// + /// Zero-copy handoff for a renderer sharing this decoder's device: the + /// images survive past the next [`decode`](Self::decode) (unlike + /// [`DecodedFrame::image`]), and are ready to sample as two separate + /// textures. Both must live on the decoder's device and be sized to the + /// frame's coded dimensions (`uv_image` at half size for 4:2:0). After the + /// call both are in `TRANSFER_DST_OPTIMAL`. + pub fn copy_frame_to_planes( + &mut self, + frame: &DecodedFrame, + y_image: vk::Image, + uv_image: vk::Image, + ) -> Result<()> { + self.0.copy_frame_to_planes(frame, y_image, uv_image) + } + + /// The Vulkan format of decoded picture images, once known. + /// + /// `None` until the first parameter set has been consumed (the format is + /// negotiated from the stream's profile). + pub fn picture_format(&self) -> Option { + self.0.picture_format() + } +} diff --git a/src/encoder/av1/init.rs b/src/encoder/av1/init.rs index 559b340..f8b6581 100644 --- a/src/encoder/av1/init.rs +++ b/src/encoder/av1/init.rs @@ -4,7 +4,7 @@ use crate::encoder::codec::{ CodecEncoder, CommonInitRequest, build_encoder_common, query_video_caps, }; use crate::encoder::{ColorDescription, EncodeConfig, PixelFormat}; -use crate::error::Result; +use crate::error::{PixelForgeError, Result}; use crate::vulkan::VideoContext; use ash::vk; use ash::vk::TaggedStructure; @@ -13,11 +13,14 @@ use tracing::info; impl Av1 { /// Create a new AV1 encoder. pub fn create(context: VideoContext, config: EncodeConfig) -> Result> { - assert!( - config.b_frame_count == 0, - "B-frame encoding is not yet supported; set b_frame_count=0 (got {})", - config.b_frame_count - ); + // Reachable from safe caller code via `with_b_frames`, so report it + // rather than panicking inside the library. + if config.b_frame_count != 0 { + return Err(PixelForgeError::InvalidInput(format!( + "AV1 encode: B-frames are not yet supported; set b_frame_count = 0 (got {})", + config.b_frame_count + ))); + } info!( "Creating AV1 encoder: {}x{}, pixel_format={:?}", diff --git a/src/encoder/h264/init.rs b/src/encoder/h264/init.rs index b2efe1b..00c88d9 100644 --- a/src/encoder/h264/init.rs +++ b/src/encoder/h264/init.rs @@ -6,7 +6,7 @@ use crate::encoder::codec::{ use crate::encoder::dpb::{DecodedPictureBuffer, DecodedPictureBufferTrait, DpbConfig}; use crate::encoder::resources::MIN_BITSTREAM_BUFFER_SIZE; use crate::encoder::{ColorDescription, EncodeConfig, PixelFormat}; -use crate::error::Result; +use crate::error::{PixelForgeError, Result}; use crate::vulkan::VideoContext; use ash::vk; use ash::vk::TaggedStructure; @@ -15,12 +15,15 @@ use tracing::{debug, info}; impl H264 { /// Create a new H.264 encoder. pub fn create(context: VideoContext, config: EncodeConfig) -> Result> { - // B-frames are not yet supported. - assert!( - config.b_frame_count == 0, - "B-frame encoding is not yet supported; set b_frame_count=0 (got {})", - config.b_frame_count - ); + // B-frames are not yet supported. `with_b_frames` is public and accepts + // any count, so this is reachable from safe caller code: report it + // rather than panicking inside the library. + if config.b_frame_count != 0 { + return Err(PixelForgeError::InvalidInput(format!( + "H.264 encode: B-frames are not yet supported; set b_frame_count = 0 (got {})", + config.b_frame_count + ))); + } info!( "Creating H.264 encoder: {}x{}, pixel_format={:?}", diff --git a/src/encoder/h265/init.rs b/src/encoder/h265/init.rs index 9232112..e58446f 100644 --- a/src/encoder/h265/init.rs +++ b/src/encoder/h265/init.rs @@ -15,11 +15,14 @@ use tracing::info; impl H265 { /// Create a new H.265/HEVC encoder. pub fn create(context: VideoContext, config: EncodeConfig) -> Result> { - assert!( - config.b_frame_count == 0, - "B-frame encoding is not yet supported; set b_frame_count=0 (got {})", - config.b_frame_count - ); + // Reachable from safe caller code via `with_b_frames`, so report it + // rather than panicking inside the library. + if config.b_frame_count != 0 { + return Err(PixelForgeError::InvalidInput(format!( + "H.265 encode: B-frames are not yet supported; set b_frame_count = 0 (got {})", + config.b_frame_count + ))); + } info!( "Creating H.265 encoder: {}x{}, pixel_format={:?}", diff --git a/src/encoder/pipeline.rs b/src/encoder/pipeline.rs index 233506e..76cc91d 100644 --- a/src/encoder/pipeline.rs +++ b/src/encoder/pipeline.rs @@ -266,6 +266,7 @@ impl EncodePipeline { let (bitstream_buffer, bitstream_buffer_memory) = create_bitstream_buffer( context, config.bitstream_buffer_size, + vk::BufferUsageFlags::VIDEO_ENCODE_DST_KHR, config.profile_info, )?; let bitstream_buffer_ptr = map_bitstream_buffer( diff --git a/src/encoder/resources.rs b/src/encoder/resources.rs index bc2fe5e..41529ad 100644 --- a/src/encoder/resources.rs +++ b/src/encoder/resources.rs @@ -5,247 +5,84 @@ use ash::vk; use ash::vk::TaggedStructure; use std::ptr; -/// Minimum bitstream buffer size. -pub(crate) const MIN_BITSTREAM_BUFFER_SIZE: usize = 2 * 1024 * 1024; - -/// Compute greatest common divisor of two values. -pub(crate) fn gcd(mut a: u32, mut b: u32) -> u32 { - while b != 0 { - let tmp = a % b; - a = b; - b = tmp; - } - a -} - -/// Compute least common multiple of two values. -pub(crate) fn lcm(a: u32, b: u32) -> u32 { - if a == 0 || b == 0 { - 0 - } else { - (a / gcd(a, b)).saturating_mul(b) - } -} - -/// Align a value up to the next multiple of the given alignment. -pub(crate) fn align_up(value: u32, alignment: u32) -> u32 { - if alignment <= 1 { - value - } else { - value.div_ceil(alignment) * alignment - } -} - -pub(crate) fn query_supported_video_formats( +// Shared, direction-agnostic Vulkan Video helpers live in `crate::video`. +// Re-export them so encoder-internal call sites keep working unchanged. +#[cfg(test)] +pub(crate) use crate::video::gcd; +pub(crate) use crate::video::{ + align_up, allocate_session_memory, create_bitstream_buffer, create_buffer_with_device_address, + create_dpb_images as create_dpb_images_shared, create_timeline_semaphore, create_video_image, + find_memory_type, get_video_format, lcm, map_bitstream_buffer, query_supported_video_formats, +}; + +/// Create the encoder's DPB images. +/// +/// Thin wrapper over [`crate::video::create_dpb_images`] that supplies the +/// encode-side usage flags; encode DPB images are only ever touched by the +/// video encode queue, so they need no queue sharing. +#[allow(clippy::too_many_arguments)] +pub(crate) fn create_dpb_images( context: &VideoContext, + width: u32, + height: u32, + format: vk::Format, + count: usize, profile_info: &vk::VideoProfileInfoKHR, - image_usage: vk::ImageUsageFlags, -) -> Result> { - let video_queue_fn = ash::khr::video_queue::Instance::load(context.entry(), context.instance()); - - // Vulkan expects a profile list in the pNext chain. - let profiles = [*profile_info]; - let mut profile_list = vk::VideoProfileListInfoKHR::default().profiles(&profiles); - - let format_info = vk::PhysicalDeviceVideoFormatInfoKHR::default() - .image_usage(image_usage) - .push(&mut profile_list); - - let physical_device = context.physical_device(); - let mut count = 0u32; - let result = unsafe { - (video_queue_fn - .fp() - .get_physical_device_video_format_properties_khr)( - physical_device, - &format_info, - &mut count, - ptr::null_mut(), - ) - }; - - if result != vk::Result::SUCCESS { - return Err(PixelForgeError::NoSuitableDevice(format!( - "Failed to query video format properties for usage {:?}: {:?}", - image_usage, result - ))); - } - - if count == 0 { - return Ok(Vec::new()); - } - - let mut props = vec![vk::VideoFormatPropertiesKHR::default(); count as usize]; - let result = unsafe { - (video_queue_fn - .fp() - .get_physical_device_video_format_properties_khr)( - physical_device, - &format_info, - &mut count, - props.as_mut_ptr(), - ) - }; - - if result != vk::Result::SUCCESS { - return Err(PixelForgeError::NoSuitableDevice(format!( - "Failed to enumerate video format properties for usage {:?}: {:?}", - image_usage, result - ))); - } - - props.truncate(count as usize); - Ok(props.into_iter().map(|p| p.format).collect()) -} - -/// Get the Vulkan format for a given pixel format and bit depth. -/// -/// Supports YUV420 and YUV444 in 8-bit and 10-bit. -/// For YUV444, uses 2-plane (semi-planar) formats from VK_EXT_ycbcr_2plane_444_formats -/// which are supported by NVIDIA hardware for video encoding. -pub(crate) fn get_video_format(pixel_format: PixelFormat, bit_depth: BitDepth) -> vk::Format { - match (pixel_format, bit_depth) { - (PixelFormat::Yuv420, BitDepth::Eight) => vk::Format::G8_B8R8_2PLANE_420_UNORM, - (PixelFormat::Yuv420, BitDepth::Ten) => { - vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16 - } - // Use 2-plane semi-planar formats for YUV444 (supported by NVIDIA for video encoding). - (PixelFormat::Yuv444, BitDepth::Eight) => vk::Format::G8_B8R8_2PLANE_444_UNORM, - (PixelFormat::Yuv444, BitDepth::Ten) => { - vk::Format::G10X6_B10X6R10X6_2PLANE_444_UNORM_3PACK16 - } - // TODO: Add support for YUV422 formats. - _ => unimplemented!( - "Unsupported pixel format / bit depth combination: {:?} / {:?}", - pixel_format, - bit_depth - ), - } -} - -/// Create a buffer that requires device addresses (SHADER_DEVICE_ADDRESS usage). -/// -/// This allocates memory with `VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT` so that -/// `get_buffer_device_address` returns a valid address. -pub(crate) fn create_buffer_with_device_address( - device: &ash::Device, - memory_properties: &vk::PhysicalDeviceMemoryProperties, - size: vk::DeviceSize, - usage: vk::BufferUsageFlags, - properties: vk::MemoryPropertyFlags, -) -> Result<(vk::Buffer, vk::DeviceMemory)> { - let buffer_info = vk::BufferCreateInfo::default() - .size(size) - .usage(usage) - .sharing_mode(vk::SharingMode::EXCLUSIVE); - - let buffer = unsafe { device.create_buffer(&buffer_info, None) } - .map_err(|e| PixelForgeError::ResourceCreation(format!("buffer creation: {}", e)))?; - - let mem_requirements = unsafe { device.get_buffer_memory_requirements(buffer) }; - - let memory_type_index = find_memory_type( - memory_properties, - mem_requirements.memory_type_bits, - properties, + use_layered: bool, +) -> Result<(Vec, Vec, Vec)> { + create_dpb_images_shared( + context, + width, + height, + format, + count, + vk::ImageUsageFlags::VIDEO_ENCODE_DPB_KHR, + &[], + profile_info, + use_layered, ) - .ok_or_else(|| { - PixelForgeError::MemoryAllocation(format!( - "No suitable memory type for buffer with properties {:?}", - properties - )) - })?; - - let mut alloc_flags_info = - vk::MemoryAllocateFlagsInfo::default().flags(vk::MemoryAllocateFlags::DEVICE_ADDRESS); - let alloc_info = vk::MemoryAllocateInfo::default() - .allocation_size(mem_requirements.size) - .memory_type_index(memory_type_index) - .push(&mut alloc_flags_info); - - let memory = match unsafe { device.allocate_memory(&alloc_info, None) } { - Ok(m) => m, - Err(e) => { - unsafe { device.destroy_buffer(buffer, None) }; - return Err(PixelForgeError::MemoryAllocation(e.to_string())); - } - }; - - match unsafe { device.bind_buffer_memory(buffer, memory, 0) } { - Ok(()) => Ok((buffer, memory)), - Err(e) => { - unsafe { - device.destroy_buffer(buffer, None); - device.free_memory(memory, None); - } - Err(PixelForgeError::MemoryAllocation(e.to_string())) - } - } -} - -pub(crate) fn find_memory_type( - memory_props: &vk::PhysicalDeviceMemoryProperties, - type_filter: u32, - properties: vk::MemoryPropertyFlags, -) -> Option { - (0..memory_props.memory_type_count).find(|&i| { - (type_filter & (1 << i)) != 0 - && memory_props.memory_types[i as usize] - .property_flags - .contains(properties) - }) } -pub(crate) fn create_bitstream_buffer( +/// Create an image for video encoding (input or DPB). +/// +/// Thin encoder-specific wrapper over [`crate::video::create_video_image`]: +/// picks the encode usage flags and the queue families that may touch an +/// encode input image (encode + transfer + compute). +pub(crate) fn create_image( context: &VideoContext, - size: usize, + width: u32, + height: u32, + format: vk::Format, + is_dpb: bool, profile_info: &vk::VideoProfileInfoKHR, -) -> Result<(vk::Buffer, vk::DeviceMemory)> { - let profiles = [*profile_info]; - let mut profile_list = vk::VideoProfileListInfoKHR::default().profiles(&profiles); - - let create_info = vk::BufferCreateInfo::default() - .size(size as vk::DeviceSize) - .usage(vk::BufferUsageFlags::VIDEO_ENCODE_DST_KHR) - .sharing_mode(vk::SharingMode::EXCLUSIVE) - .push(&mut profile_list); - - let buffer = unsafe { context.device().create_buffer(&create_info, None) } - .map_err(|e| PixelForgeError::ResourceCreation(format!("buffer creation: {}", e)))?; - - let mem_requirements = unsafe { context.device().get_buffer_memory_requirements(buffer) }; - - let memory_type_index = find_memory_type( - context.memory_properties(), - mem_requirements.memory_type_bits, - vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, +) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> { + let (usage, families) = if is_dpb { + (vk::ImageUsageFlags::VIDEO_ENCODE_DPB_KHR, Vec::new()) + } else { + let mut families = Vec::new(); + if let Some(encode_family) = context.video_encode_queue_family() { + families.push(encode_family); + families.push(context.transfer_queue_family()); + families.push(context.compute_queue_family()); + } + ( + vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR | vk::ImageUsageFlags::TRANSFER_DST, + families, + ) + }; + create_video_image( + context, + width, + height, + format, + usage, + &families, + profile_info, ) - .ok_or_else(|| { - PixelForgeError::MemoryAllocation("No suitable memory type for buffer".to_string()) - })?; - - let alloc_info = vk::MemoryAllocateInfo::default() - .allocation_size(mem_requirements.size) - .memory_type_index(memory_type_index); - - let memory = unsafe { context.device().allocate_memory(&alloc_info, None) } - .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; - - unsafe { context.device().bind_buffer_memory(buffer, memory, 0) } - .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; - - Ok((buffer, memory)) } -pub(crate) fn create_timeline_semaphore(context: &VideoContext) -> Result { - let mut type_info = vk::SemaphoreTypeCreateInfo::default() - .semaphore_type(vk::SemaphoreType::TIMELINE) - .initial_value(0); - let create_info = vk::SemaphoreCreateInfo::default().push(&mut type_info); - - unsafe { context.device().create_semaphore(&create_info, None) } - .map_err(|e| PixelForgeError::Synchronization(e.to_string())) -} +/// Minimum bitstream buffer size. +pub(crate) const MIN_BITSTREAM_BUFFER_SIZE: usize = 2 * 1024 * 1024; pub(crate) fn create_encode_feedback_query_pool( context: &VideoContext, @@ -286,228 +123,6 @@ pub(crate) fn create_encode_timestamp_query_pool(context: &VideoContext) -> Resu .map_err(|e| PixelForgeError::QueryPool(e.to_string())) } -/// Create an image for video encoding (input or DPB). -/// -/// This creates a VkImage suitable for use with a video encoder. -/// For DPB images, the usage is VIDEO_ENCODE_DPB_KHR. -/// For input images, the usage is VIDEO_ENCODE_SRC_KHR | TRANSFER_DST. -/// -/// # Arguments -/// * `context` - The Vulkan video context -/// * `width` - Image width in pixels -/// * `height` - Image height in pixels -/// * `format` - The Vulkan format to use for the image -/// * `is_dpb` - If true, create a DPB image; if false, create an input image -/// * `profile_info` - Video profile info for the encoder session -pub(crate) fn create_image( - context: &VideoContext, - width: u32, - height: u32, - format: vk::Format, - is_dpb: bool, - profile_info: &vk::VideoProfileInfoKHR, -) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> { - let usage = if is_dpb { - vk::ImageUsageFlags::VIDEO_ENCODE_DPB_KHR - } else { - vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR | vk::ImageUsageFlags::TRANSFER_DST - }; - - // For input (non-DPB) images, use CONCURRENT sharing mode when multiple - // queue families need access. The image may be accessed by: - // - The video encode queue (for encoding) - // - The transfer queue (for InputImage upload) - // - The compute queue (for ColorConverter buffer-to-image copy) - let mut queue_families = Vec::new(); - let sharing_mode = if !is_dpb { - if let Some(encode_family) = context.video_encode_queue_family() { - queue_families.push(encode_family); - let transfer_family = context.transfer_queue_family(); - if !queue_families.contains(&transfer_family) { - queue_families.push(transfer_family); - } - let compute_family = context.compute_queue_family(); - if !queue_families.contains(&compute_family) { - queue_families.push(compute_family); - } - } - if queue_families.len() > 1 { - vk::SharingMode::CONCURRENT - } else { - queue_families.clear(); - vk::SharingMode::EXCLUSIVE - } - } else { - vk::SharingMode::EXCLUSIVE - }; - - let profiles = [*profile_info]; - let mut profile_list = vk::VideoProfileListInfoKHR::default().profiles(&profiles); - - let create_info = vk::ImageCreateInfo::default() - .image_type(vk::ImageType::TYPE_2D) - .format(format) - .extent(vk::Extent3D { - width, - height, - depth: 1, - }) - .mip_levels(1) - .array_layers(1) - .samples(vk::SampleCountFlags::TYPE_1) - .tiling(vk::ImageTiling::OPTIMAL) - .usage(usage) - .sharing_mode(sharing_mode) - .queue_family_indices(&queue_families) - .initial_layout(vk::ImageLayout::UNDEFINED) - .push(&mut profile_list); - - let image = unsafe { context.device().create_image(&create_info, None) } - .map_err(|e| PixelForgeError::ResourceCreation(format!("image creation: {}", e)))?; - - let mem_requirements = unsafe { context.device().get_image_memory_requirements(image) }; - - let memory_type_index = find_memory_type( - context.memory_properties(), - mem_requirements.memory_type_bits, - vk::MemoryPropertyFlags::DEVICE_LOCAL, - ) - .ok_or_else(|| { - PixelForgeError::MemoryAllocation("No suitable memory type for image".to_string()) - })?; - - let alloc_info = vk::MemoryAllocateInfo::default() - .allocation_size(mem_requirements.size) - .memory_type_index(memory_type_index); - - let memory = unsafe { context.device().allocate_memory(&alloc_info, None) } - .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; - - unsafe { context.device().bind_image_memory(image, memory, 0) } - .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; - - let view_create_info = vk::ImageViewCreateInfo::default() - .image(image) - .view_type(vk::ImageViewType::TYPE_2D) - .format(format) - .components(vk::ComponentMapping { - r: vk::ComponentSwizzle::IDENTITY, - g: vk::ComponentSwizzle::IDENTITY, - b: vk::ComponentSwizzle::IDENTITY, - a: vk::ComponentSwizzle::IDENTITY, - }) - .subresource_range(vk::ImageSubresourceRange { - aspect_mask: vk::ImageAspectFlags::COLOR, - base_mip_level: 0, - level_count: 1, - base_array_layer: 0, - layer_count: 1, - }); - - let view = unsafe { context.device().create_image_view(&view_create_info, None) } - .map_err(|e| PixelForgeError::ResourceCreation(format!("image view creation: {}", e)))?; - - Ok((image, memory, view)) -} -/// Allocate and bind memory for a video session. -/// -/// Returns the allocated device memory handles. -pub(crate) fn allocate_session_memory( - context: &VideoContext, - session: vk::VideoSessionKHR, - video_queue_fn: &ash::khr::video_queue::Device, -) -> Result> { - // Query memory requirements count. - let mut memory_requirements_count = 0u32; - let result = unsafe { - (video_queue_fn - .fp() - .get_video_session_memory_requirements_khr)( - context.device().handle(), - session, - &mut memory_requirements_count, - ptr::null_mut(), - ) - }; - if result != vk::Result::SUCCESS { - return Err(PixelForgeError::MemoryAllocation(format!("{:?}", result))); - } - - // Query actual requirements. - let mut memory_requirements = - vec![vk::VideoSessionMemoryRequirementsKHR::default(); memory_requirements_count as usize]; - let result = unsafe { - (video_queue_fn - .fp() - .get_video_session_memory_requirements_khr)( - context.device().handle(), - session, - &mut memory_requirements_count, - memory_requirements.as_mut_ptr(), - ) - }; - if result != vk::Result::SUCCESS { - return Err(PixelForgeError::MemoryAllocation(format!("{:?}", result))); - } - - // Allocate and bind memory for each requirement. - let mut session_memory = Vec::new(); - let mut bind_infos = Vec::new(); - - for req in &memory_requirements { - let memory_type_index = find_memory_type( - context.memory_properties(), - req.memory_requirements.memory_type_bits, - vk::MemoryPropertyFlags::DEVICE_LOCAL, - ) - .or_else(|| { - find_memory_type( - context.memory_properties(), - req.memory_requirements.memory_type_bits, - vk::MemoryPropertyFlags::empty(), - ) - }) - .ok_or_else(|| { - PixelForgeError::MemoryAllocation(format!( - "No suitable memory type for video session (type_bits: 0x{:x})", - req.memory_requirements.memory_type_bits - )) - })?; - - let alloc_info = vk::MemoryAllocateInfo::default() - .allocation_size(req.memory_requirements.size) - .memory_type_index(memory_type_index); - - let memory = unsafe { context.device().allocate_memory(&alloc_info, None) } - .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; - - bind_infos.push( - vk::BindVideoSessionMemoryInfoKHR::default() - .memory_bind_index(req.memory_bind_index) - .memory(memory) - .memory_offset(0) - .memory_size(req.memory_requirements.size), - ); - - session_memory.push(memory); - } - - // Bind all memory to the session. - let result = unsafe { - (video_queue_fn.fp().bind_video_session_memory_khr)( - context.device().handle(), - session, - bind_infos.len() as u32, - bind_infos.as_ptr(), - ) - }; - if result != vk::Result::SUCCESS { - return Err(PixelForgeError::MemoryAllocation(format!("{:?}", result))); - } - - Ok(session_memory) -} - /// Command resources for encoding operations. pub(crate) struct CommandResources { /// Command pool for encode commands. @@ -596,152 +211,6 @@ pub(crate) fn create_command_resources( /// /// When `use_layered` is false the previous behaviour is preserved: one /// separate image/memory/view per DPB slot. -pub(crate) fn create_dpb_images( - context: &VideoContext, - width: u32, - height: u32, - format: vk::Format, - count: usize, - profile_info: &vk::VideoProfileInfoKHR, - use_layered: bool, -) -> Result<(Vec, Vec, Vec)> { - if use_layered { - // Create a single image with `count` array layers. - let profiles = [*profile_info]; - let mut profile_list = vk::VideoProfileListInfoKHR::default().profiles(&profiles); - - let create_info = vk::ImageCreateInfo::default() - .image_type(vk::ImageType::TYPE_2D) - .format(format) - .extent(vk::Extent3D { - width, - height, - depth: 1, - }) - .mip_levels(1) - .array_layers(count as u32) - .samples(vk::SampleCountFlags::TYPE_1) - .tiling(vk::ImageTiling::OPTIMAL) - .usage(vk::ImageUsageFlags::VIDEO_ENCODE_DPB_KHR) - .sharing_mode(vk::SharingMode::EXCLUSIVE) - .initial_layout(vk::ImageLayout::UNDEFINED) - .push(&mut profile_list); - - let image = unsafe { context.device().create_image(&create_info, None) } - .map_err(|e| PixelForgeError::ResourceCreation(format!("layered DPB image: {}", e)))?; - - let mem_requirements = unsafe { context.device().get_image_memory_requirements(image) }; - - let memory_type_index = find_memory_type( - context.memory_properties(), - mem_requirements.memory_type_bits, - vk::MemoryPropertyFlags::DEVICE_LOCAL, - ) - .ok_or_else(|| { - PixelForgeError::MemoryAllocation( - "No suitable memory type for layered DPB image".to_string(), - ) - })?; - - let alloc_info = vk::MemoryAllocateInfo::default() - .allocation_size(mem_requirements.size) - .memory_type_index(memory_type_index); - - let memory = unsafe { context.device().allocate_memory(&alloc_info, None) } - .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; - - unsafe { context.device().bind_image_memory(image, memory, 0) } - .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; - - // Create one view per array layer. - let mut dpb_image_views = Vec::with_capacity(count); - for layer in 0..count as u32 { - let view_create_info = vk::ImageViewCreateInfo::default() - .image(image) - .view_type(vk::ImageViewType::TYPE_2D) - .format(format) - .components(vk::ComponentMapping { - r: vk::ComponentSwizzle::IDENTITY, - g: vk::ComponentSwizzle::IDENTITY, - b: vk::ComponentSwizzle::IDENTITY, - a: vk::ComponentSwizzle::IDENTITY, - }) - .subresource_range(vk::ImageSubresourceRange { - aspect_mask: vk::ImageAspectFlags::COLOR, - base_mip_level: 0, - level_count: 1, - base_array_layer: layer, - layer_count: 1, - }); - - let view = unsafe { context.device().create_image_view(&view_create_info, None) } - .map_err(|e| { - PixelForgeError::ResourceCreation(format!( - "layered DPB image view layer {}: {}", - layer, e - )) - })?; - dpb_image_views.push(view); - } - - Ok((vec![image], vec![memory], dpb_image_views)) - } else { - let mut dpb_images = Vec::with_capacity(count); - let mut dpb_image_memories = Vec::with_capacity(count); - let mut dpb_image_views = Vec::with_capacity(count); - - for _ in 0..count { - let (dpb_image, dpb_image_memory, dpb_image_view) = - create_image(context, width, height, format, true, profile_info)?; - dpb_images.push(dpb_image); - dpb_image_memories.push(dpb_image_memory); - dpb_image_views.push(dpb_image_view); - } - - Ok((dpb_images, dpb_image_memories, dpb_image_views)) - } -} - -/// Map a bitstream buffer for persistent access. -pub(crate) fn map_bitstream_buffer( - context: &VideoContext, - memory: vk::DeviceMemory, - size: usize, -) -> Result<*mut u8> { - let ptr = unsafe { - context.device().map_memory( - memory, - 0, - size as vk::DeviceSize, - vk::MemoryMapFlags::empty(), - ) - } - .map_err(|e| { - PixelForgeError::MemoryAllocation(format!("Failed to map bitstream buffer: {}", e)) - })? as *mut u8; - - Ok(ptr) -} - -/// Parameters for clearing the input image at initialization. -pub(crate) struct ClearImageParams { - pub command_buffer: vk::CommandBuffer, - pub fence: vk::Fence, - pub queue: vk::Queue, - pub image: vk::Image, - pub width: u32, - pub height: u32, - pub pixel_format: PixelFormat, - pub bit_depth: BitDepth, -} - -/// Clear the input image by filling it with zeros via a staging buffer. -/// -/// This must be called once after creating the input image to ensure -/// the padding region (between the user dimensions and the aligned coded -/// extent) contains defined values. Without this, the first frame's -/// padding is undefined, which can cause encoding artifacts on strict -/// drivers. pub(crate) fn clear_input_image(context: &VideoContext, params: &ClearImageParams) -> Result<()> { let device = context.device(); let bytes_per_component: u32 = match params.bit_depth { @@ -1693,3 +1162,14 @@ mod tests { assert_eq!(align_up(130, lcm(32, 16)), 160); } } + +pub(crate) struct ClearImageParams { + pub command_buffer: vk::CommandBuffer, + pub fence: vk::Fence, + pub queue: vk::Queue, + pub image: vk::Image, + pub width: u32, + pub height: u32, + pub pixel_format: PixelFormat, + pub bit_depth: BitDepth, +} diff --git a/src/error.rs b/src/error.rs index 9aa7a73..b001878 100644 --- a/src/error.rs +++ b/src/error.rs @@ -41,6 +41,17 @@ pub enum PixelForgeError { #[error("Invalid input: {0}")] InvalidInput(String), + /// The decoder cannot proceed until it sees a keyframe (IDR). + /// + /// Returned by `decode` for pictures that reference decode state not yet + /// established — parameter sets or reference pictures that have not been + /// seen, which is the normal situation when joining a stream mid-flight or + /// recovering from loss. It is a routine control-flow signal, not a fault: + /// the caller should request an IDR from the sender and keep going, not log + /// it as an error. `{0}` describes what was missing. + #[error("Awaiting keyframe: {0}")] + NeedsKeyframe(String), + /// Codec not supported. #[error("Codec not supported: {0}")] CodecNotSupported(String), diff --git a/src/lib.rs b/src/lib.rs index cbb8269..d76cf3a 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -190,9 +190,11 @@ //! repository by NVIDIA, which provided invaluable reference for Vulkan Video encoding. pub mod converter; +pub mod decoder; pub mod encoder; pub mod error; pub mod image; +pub(crate) mod video; pub mod vulkan; /// Align a byte size up to a multiple of 4. diff --git a/src/video.rs b/src/video.rs new file mode 100644 index 0000000..f06b3d3 --- /dev/null +++ b/src/video.rs @@ -0,0 +1,610 @@ +//! Codec- and direction-agnostic Vulkan Video helpers. +//! +//! Everything here is shared between the encoder and the decoder: format and +//! capability queries, video-profile-tagged image and buffer creation, video +//! session memory binding, and small arithmetic helpers. Direction-specific +//! resource management (encode slot pipelines, decode DPB pools) lives in +//! `encoder::resources` and `decoder` respectively, built on these primitives. + +use crate::encoder::{BitDepth, PixelFormat}; +use crate::error::{PixelForgeError, Result}; +use crate::vulkan::VideoContext; +use ash::vk; +use ash::vk::TaggedStructure; +use std::ptr; + +/// Compute greatest common divisor of two values. +pub(crate) fn gcd(mut a: u32, mut b: u32) -> u32 { + while b != 0 { + let tmp = a % b; + a = b; + b = tmp; + } + a +} + +/// Compute least common multiple of two values. +pub(crate) fn lcm(a: u32, b: u32) -> u32 { + if a == 0 || b == 0 { + 0 + } else { + (a / gcd(a, b)).saturating_mul(b) + } +} + +/// Align a value up to the next multiple of the given alignment. +pub(crate) fn align_up(value: u32, alignment: u32) -> u32 { + if alignment <= 1 { + value + } else { + value.div_ceil(alignment) * alignment + } +} + +pub(crate) fn query_supported_video_formats( + context: &VideoContext, + profile_info: &vk::VideoProfileInfoKHR, + image_usage: vk::ImageUsageFlags, +) -> Result> { + let video_queue_fn = ash::khr::video_queue::Instance::load(context.entry(), context.instance()); + + // Vulkan expects a profile list in the pNext chain. + let profiles = [*profile_info]; + let mut profile_list = vk::VideoProfileListInfoKHR::default().profiles(&profiles); + + let format_info = vk::PhysicalDeviceVideoFormatInfoKHR::default() + .image_usage(image_usage) + .push(&mut profile_list); + + let physical_device = context.physical_device(); + let mut count = 0u32; + let result = unsafe { + (video_queue_fn + .fp() + .get_physical_device_video_format_properties_khr)( + physical_device, + &format_info, + &mut count, + ptr::null_mut(), + ) + }; + + if result != vk::Result::SUCCESS { + return Err(PixelForgeError::NoSuitableDevice(format!( + "Failed to query video format properties for usage {:?}: {:?}", + image_usage, result + ))); + } + + if count == 0 { + return Ok(Vec::new()); + } + + let mut props = vec![vk::VideoFormatPropertiesKHR::default(); count as usize]; + let result = unsafe { + (video_queue_fn + .fp() + .get_physical_device_video_format_properties_khr)( + physical_device, + &format_info, + &mut count, + props.as_mut_ptr(), + ) + }; + + if result != vk::Result::SUCCESS { + return Err(PixelForgeError::NoSuitableDevice(format!( + "Failed to enumerate video format properties for usage {:?}: {:?}", + image_usage, result + ))); + } + + props.truncate(count as usize); + Ok(props.into_iter().map(|p| p.format).collect()) +} + +/// Get the Vulkan format for a given pixel format and bit depth. +/// +/// Supports YUV420 and YUV444 in 8-bit and 10-bit. +/// For YUV444, uses 2-plane (semi-planar) formats from VK_EXT_ycbcr_2plane_444_formats +/// which are supported by NVIDIA hardware for video encoding. +pub(crate) fn get_video_format(pixel_format: PixelFormat, bit_depth: BitDepth) -> vk::Format { + match (pixel_format, bit_depth) { + (PixelFormat::Yuv420, BitDepth::Eight) => vk::Format::G8_B8R8_2PLANE_420_UNORM, + (PixelFormat::Yuv420, BitDepth::Ten) => { + vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16 + } + // Use 2-plane semi-planar formats for YUV444 (supported by NVIDIA for video encoding). + (PixelFormat::Yuv444, BitDepth::Eight) => vk::Format::G8_B8R8_2PLANE_444_UNORM, + (PixelFormat::Yuv444, BitDepth::Ten) => { + vk::Format::G10X6_B10X6R10X6_2PLANE_444_UNORM_3PACK16 + } + // TODO: Add support for YUV422 formats. + _ => unimplemented!( + "Unsupported pixel format / bit depth combination: {:?} / {:?}", + pixel_format, + bit_depth + ), + } +} + +/// Create a buffer that requires device addresses (SHADER_DEVICE_ADDRESS usage). +/// +/// This allocates memory with `VK_MEMORY_ALLOCATE_DEVICE_ADDRESS_BIT` so that +/// `get_buffer_device_address` returns a valid address. +pub(crate) fn create_buffer_with_device_address( + device: &ash::Device, + memory_properties: &vk::PhysicalDeviceMemoryProperties, + size: vk::DeviceSize, + usage: vk::BufferUsageFlags, + properties: vk::MemoryPropertyFlags, +) -> Result<(vk::Buffer, vk::DeviceMemory)> { + let buffer_info = vk::BufferCreateInfo::default() + .size(size) + .usage(usage) + .sharing_mode(vk::SharingMode::EXCLUSIVE); + + let buffer = unsafe { device.create_buffer(&buffer_info, None) } + .map_err(|e| PixelForgeError::ResourceCreation(format!("buffer creation: {}", e)))?; + + let mem_requirements = unsafe { device.get_buffer_memory_requirements(buffer) }; + + let memory_type_index = find_memory_type( + memory_properties, + mem_requirements.memory_type_bits, + properties, + ) + .ok_or_else(|| { + PixelForgeError::MemoryAllocation(format!( + "No suitable memory type for buffer with properties {:?}", + properties + )) + })?; + + let mut alloc_flags_info = + vk::MemoryAllocateFlagsInfo::default().flags(vk::MemoryAllocateFlags::DEVICE_ADDRESS); + let alloc_info = vk::MemoryAllocateInfo::default() + .allocation_size(mem_requirements.size) + .memory_type_index(memory_type_index) + .push(&mut alloc_flags_info); + + let memory = match unsafe { device.allocate_memory(&alloc_info, None) } { + Ok(m) => m, + Err(e) => { + unsafe { device.destroy_buffer(buffer, None) }; + return Err(PixelForgeError::MemoryAllocation(e.to_string())); + } + }; + + match unsafe { device.bind_buffer_memory(buffer, memory, 0) } { + Ok(()) => Ok((buffer, memory)), + Err(e) => { + unsafe { + device.destroy_buffer(buffer, None); + device.free_memory(memory, None); + } + Err(PixelForgeError::MemoryAllocation(e.to_string())) + } + } +} + +pub(crate) fn find_memory_type( + memory_props: &vk::PhysicalDeviceMemoryProperties, + type_filter: u32, + properties: vk::MemoryPropertyFlags, +) -> Option { + (0..memory_props.memory_type_count).find(|&i| { + (type_filter & (1 << i)) != 0 + && memory_props.memory_types[i as usize] + .property_flags + .contains(properties) + }) +} + +pub(crate) fn create_bitstream_buffer( + context: &VideoContext, + size: usize, + usage: vk::BufferUsageFlags, + profile_info: &vk::VideoProfileInfoKHR, +) -> Result<(vk::Buffer, vk::DeviceMemory)> { + let profiles = [*profile_info]; + let mut profile_list = vk::VideoProfileListInfoKHR::default().profiles(&profiles); + + let create_info = vk::BufferCreateInfo::default() + .size(size as vk::DeviceSize) + .usage(usage) + .sharing_mode(vk::SharingMode::EXCLUSIVE) + .push(&mut profile_list); + + let buffer = unsafe { context.device().create_buffer(&create_info, None) } + .map_err(|e| PixelForgeError::ResourceCreation(format!("buffer creation: {}", e)))?; + + let mem_requirements = unsafe { context.device().get_buffer_memory_requirements(buffer) }; + + let memory_type_index = find_memory_type( + context.memory_properties(), + mem_requirements.memory_type_bits, + vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT, + ) + .ok_or_else(|| { + PixelForgeError::MemoryAllocation("No suitable memory type for buffer".to_string()) + })?; + + let alloc_info = vk::MemoryAllocateInfo::default() + .allocation_size(mem_requirements.size) + .memory_type_index(memory_type_index); + + let memory = unsafe { context.device().allocate_memory(&alloc_info, None) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + + unsafe { context.device().bind_buffer_memory(buffer, memory, 0) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + + Ok((buffer, memory)) +} + +pub(crate) fn create_timeline_semaphore(context: &VideoContext) -> Result { + let mut type_info = vk::SemaphoreTypeCreateInfo::default() + .semaphore_type(vk::SemaphoreType::TIMELINE) + .initial_value(0); + let create_info = vk::SemaphoreCreateInfo::default().push(&mut type_info); + + unsafe { context.device().create_semaphore(&create_info, None) } + .map_err(|e| PixelForgeError::Synchronization(e.to_string())) +} + +/// Create an image for video encoding (input or DPB). +/// +/// This creates a VkImage suitable for use with a video encoder. +/// For DPB images, the usage is VIDEO_ENCODE_DPB_KHR. +/// For input images, the usage is VIDEO_ENCODE_SRC_KHR | TRANSFER_DST. +/// +/// # Arguments +/// * `context` - The Vulkan video context +/// * `width` - Image width in pixels +/// * `height` - Image height in pixels +/// * `format` - The Vulkan format to use for the image +/// * `is_dpb` - If true, create a DPB image; if false, create an input image +/// * `profile_info` - Video profile info for the encoder session +pub(crate) fn create_video_image( + context: &VideoContext, + width: u32, + height: u32, + format: vk::Format, + usage: vk::ImageUsageFlags, + sharing_families: &[u32], + profile_info: &vk::VideoProfileInfoKHR, +) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> { + // Use CONCURRENT sharing mode when multiple queue families need access + // (e.g. video queue + transfer queue for upload/readback, compute for + // color conversion). Callers pass the deduplicated family list. + let mut queue_families: Vec = Vec::new(); + for &family in sharing_families { + if !queue_families.contains(&family) { + queue_families.push(family); + } + } + let sharing_mode = if queue_families.len() > 1 { + vk::SharingMode::CONCURRENT + } else { + queue_families.clear(); + vk::SharingMode::EXCLUSIVE + }; + + let profiles = [*profile_info]; + let mut profile_list = vk::VideoProfileListInfoKHR::default().profiles(&profiles); + + let create_info = vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(format) + .extent(vk::Extent3D { + width, + height, + depth: 1, + }) + .mip_levels(1) + .array_layers(1) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(usage) + .sharing_mode(sharing_mode) + .queue_family_indices(&queue_families) + .initial_layout(vk::ImageLayout::UNDEFINED) + .push(&mut profile_list); + + let image = unsafe { context.device().create_image(&create_info, None) } + .map_err(|e| PixelForgeError::ResourceCreation(format!("image creation: {}", e)))?; + + let mem_requirements = unsafe { context.device().get_image_memory_requirements(image) }; + + let memory_type_index = find_memory_type( + context.memory_properties(), + mem_requirements.memory_type_bits, + vk::MemoryPropertyFlags::DEVICE_LOCAL, + ) + .ok_or_else(|| { + PixelForgeError::MemoryAllocation("No suitable memory type for image".to_string()) + })?; + + let alloc_info = vk::MemoryAllocateInfo::default() + .allocation_size(mem_requirements.size) + .memory_type_index(memory_type_index); + + let memory = unsafe { context.device().allocate_memory(&alloc_info, None) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + + unsafe { context.device().bind_image_memory(image, memory, 0) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + + let view_create_info = vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(format) + .components(vk::ComponentMapping { + r: vk::ComponentSwizzle::IDENTITY, + g: vk::ComponentSwizzle::IDENTITY, + b: vk::ComponentSwizzle::IDENTITY, + a: vk::ComponentSwizzle::IDENTITY, + }) + .subresource_range(vk::ImageSubresourceRange { + aspect_mask: vk::ImageAspectFlags::COLOR, + base_mip_level: 0, + level_count: 1, + base_array_layer: 0, + layer_count: 1, + }); + + let view = unsafe { context.device().create_image_view(&view_create_info, None) } + .map_err(|e| PixelForgeError::ResourceCreation(format!("image view creation: {}", e)))?; + + Ok((image, memory, view)) +} + +/// Allocate and bind memory for a video session. +/// +/// Returns the allocated device memory handles. +pub(crate) fn allocate_session_memory( + context: &VideoContext, + session: vk::VideoSessionKHR, + video_queue_fn: &ash::khr::video_queue::Device, +) -> Result> { + // Query memory requirements count. + let mut memory_requirements_count = 0u32; + let result = unsafe { + (video_queue_fn + .fp() + .get_video_session_memory_requirements_khr)( + context.device().handle(), + session, + &mut memory_requirements_count, + ptr::null_mut(), + ) + }; + if result != vk::Result::SUCCESS { + return Err(PixelForgeError::MemoryAllocation(format!("{:?}", result))); + } + + // Query actual requirements. + let mut memory_requirements = + vec![vk::VideoSessionMemoryRequirementsKHR::default(); memory_requirements_count as usize]; + let result = unsafe { + (video_queue_fn + .fp() + .get_video_session_memory_requirements_khr)( + context.device().handle(), + session, + &mut memory_requirements_count, + memory_requirements.as_mut_ptr(), + ) + }; + if result != vk::Result::SUCCESS { + return Err(PixelForgeError::MemoryAllocation(format!("{:?}", result))); + } + + // Allocate and bind memory for each requirement. + let mut session_memory = Vec::new(); + let mut bind_infos = Vec::new(); + + for req in &memory_requirements { + let memory_type_index = find_memory_type( + context.memory_properties(), + req.memory_requirements.memory_type_bits, + vk::MemoryPropertyFlags::DEVICE_LOCAL, + ) + .or_else(|| { + find_memory_type( + context.memory_properties(), + req.memory_requirements.memory_type_bits, + vk::MemoryPropertyFlags::empty(), + ) + }) + .ok_or_else(|| { + PixelForgeError::MemoryAllocation(format!( + "No suitable memory type for video session (type_bits: 0x{:x})", + req.memory_requirements.memory_type_bits + )) + })?; + + let alloc_info = vk::MemoryAllocateInfo::default() + .allocation_size(req.memory_requirements.size) + .memory_type_index(memory_type_index); + + let memory = unsafe { context.device().allocate_memory(&alloc_info, None) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + + bind_infos.push( + vk::BindVideoSessionMemoryInfoKHR::default() + .memory_bind_index(req.memory_bind_index) + .memory(memory) + .memory_offset(0) + .memory_size(req.memory_requirements.size), + ); + + session_memory.push(memory); + } + + // Bind all memory to the session. + let result = unsafe { + (video_queue_fn.fp().bind_video_session_memory_khr)( + context.device().handle(), + session, + bind_infos.len() as u32, + bind_infos.as_ptr(), + ) + }; + if result != vk::Result::SUCCESS { + return Err(PixelForgeError::MemoryAllocation(format!("{:?}", result))); + } + + Ok(session_memory) +} + +/// Map a bitstream buffer for persistent access. +pub(crate) fn map_bitstream_buffer( + context: &VideoContext, + memory: vk::DeviceMemory, + size: usize, +) -> Result<*mut u8> { + let ptr = unsafe { + context.device().map_memory( + memory, + 0, + size as vk::DeviceSize, + vk::MemoryMapFlags::empty(), + ) + } + .map_err(|e| { + PixelForgeError::MemoryAllocation(format!("Failed to map bitstream buffer: {}", e)) + })? as *mut u8; + + Ok(ptr) +} + +/// Create the decoded picture buffer images for a video session. +/// +/// Two layouts are possible, selected by `use_layered`: +/// - **layered**: a single image with `count` array layers (required when the +/// device does not report `SEPARATE_REFERENCE_IMAGES`), with one view per layer; +/// - **separate**: `count` independent single-layer images, one view each. +/// +/// Either way the returned view vector is indexed by DPB slot, so callers need +/// not care which layout was used. `usage` and `sharing_families` differ +/// between encode and decode (decode DPB images may also serve as the decode +/// output and be copied from), so they are supplied by the caller. +pub(crate) fn create_dpb_images( + context: &VideoContext, + width: u32, + height: u32, + format: vk::Format, + count: usize, + usage: vk::ImageUsageFlags, + sharing_families: &[u32], + profile_info: &vk::VideoProfileInfoKHR, + use_layered: bool, +) -> Result<(Vec, Vec, Vec)> { + if !use_layered { + let mut images = Vec::with_capacity(count); + let mut memories = Vec::with_capacity(count); + let mut views = Vec::with_capacity(count); + for _ in 0..count { + let (image, memory, view) = create_video_image( + context, + width, + height, + format, + usage, + sharing_families, + profile_info, + )?; + images.push(image); + memories.push(memory); + views.push(view); + } + return Ok((images, memories, views)); + } + + // Layered: one image, `count` array layers, one view per layer. + let profiles = [*profile_info]; + let mut profile_list = vk::VideoProfileListInfoKHR::default().profiles(&profiles); + + let mut queue_families: Vec = Vec::new(); + for &family in sharing_families { + if !queue_families.contains(&family) { + queue_families.push(family); + } + } + let sharing_mode = if queue_families.len() > 1 { + vk::SharingMode::CONCURRENT + } else { + queue_families.clear(); + vk::SharingMode::EXCLUSIVE + }; + + let create_info = vk::ImageCreateInfo::default() + .image_type(vk::ImageType::TYPE_2D) + .format(format) + .extent(vk::Extent3D { + width, + height, + depth: 1, + }) + .mip_levels(1) + .array_layers(count as u32) + .samples(vk::SampleCountFlags::TYPE_1) + .tiling(vk::ImageTiling::OPTIMAL) + .usage(usage) + .sharing_mode(sharing_mode) + .queue_family_indices(&queue_families) + .initial_layout(vk::ImageLayout::UNDEFINED) + .push(&mut profile_list); + + let image = unsafe { context.device().create_image(&create_info, None) } + .map_err(|e| PixelForgeError::ResourceCreation(format!("layered DPB image: {}", e)))?; + + let mem_requirements = unsafe { context.device().get_image_memory_requirements(image) }; + let memory_type_index = find_memory_type( + context.memory_properties(), + mem_requirements.memory_type_bits, + vk::MemoryPropertyFlags::DEVICE_LOCAL, + ) + .ok_or_else(|| { + PixelForgeError::MemoryAllocation( + "No suitable memory type for layered DPB image".to_string(), + ) + })?; + + let alloc_info = vk::MemoryAllocateInfo::default() + .allocation_size(mem_requirements.size) + .memory_type_index(memory_type_index); + let memory = unsafe { context.device().allocate_memory(&alloc_info, None) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + unsafe { context.device().bind_image_memory(image, memory, 0) } + .map_err(|e| PixelForgeError::MemoryAllocation(e.to_string()))?; + + let mut views = Vec::with_capacity(count); + for layer in 0..count as u32 { + let view_create_info = vk::ImageViewCreateInfo::default() + .image(image) + .view_type(vk::ImageViewType::TYPE_2D) + .format(format) + .components(vk::ComponentMapping::default()) + .subresource_range(vk::ImageSubresourceRange { + aspect_mask: vk::ImageAspectFlags::COLOR, + base_mip_level: 0, + level_count: 1, + base_array_layer: layer, + layer_count: 1, + }); + let view = unsafe { context.device().create_image_view(&view_create_info, None) }.map_err( + |e| { + PixelForgeError::ResourceCreation(format!( + "layered DPB view layer {}: {}", + layer, e + )) + }, + )?; + views.push(view); + } + + Ok((vec![image], vec![memory], views)) +} diff --git a/src/vulkan.rs b/src/vulkan.rs index 9333a20..f96e1c6 100644 --- a/src/vulkan.rs +++ b/src/vulkan.rs @@ -14,6 +14,7 @@ pub struct VideoContextBuilder { app_version: (u32, u32, u32), enable_validation: bool, required_encode_codecs: Vec, + required_decode_codecs: Vec, } impl Default for VideoContextBuilder { @@ -30,6 +31,7 @@ impl VideoContextBuilder { app_version: (1, 0, 0), enable_validation: false, required_encode_codecs: Vec::new(), + required_decode_codecs: Vec::new(), } } @@ -57,10 +59,213 @@ impl VideoContextBuilder { self } + /// Require video decode support for a codec. + pub fn require_decode(mut self, codec: Codec) -> Self { + self.required_decode_codecs.push(codec); + self + } + /// Build the VideoContext. pub fn build(self) -> Result { VideoContext::new(self) } + + /// What a caller-created device must provide for pixelforge to decode on it. + /// + /// Use this when you already have your own Vulkan device (e.g. a renderer's) + /// and want to decode into images that device can use directly, without the + /// cross-device copy a separate context would require. The flow is: + /// + /// 1. Pick a physical device that supports both your needs and decode (this + /// call fails if `physical_device` cannot decode the required codecs). + /// 2. Merge the returned queue families and extensions with your own, create + /// one logical device, and enable the `synchronization2` feature. + /// 3. Adopt it with [`VideoContext::from_existing_decode`]. + /// + /// Only the decode path is covered; encoding still needs [`Self::build`]. + pub fn decode_device_requirements( + &self, + entry: &ash::Entry, + instance: &ash::Instance, + physical_device: vk::PhysicalDevice, + ) -> Result { + let families = find_decode_queue_families( + entry, + instance, + physical_device, + &self.required_decode_codecs, + )?; + Ok(DeviceRequirements { + queue_families: families.unique(), + extensions: decode_extension_names(&self.required_decode_codecs), + }) + } + + /// Adopt a caller-created device for decoding, rather than creating one. + /// + /// The device must have been created with the queue families and extensions + /// reported by [`Self::decode_device_requirements`] for the same + /// `physical_device`, and with the `synchronization2` feature enabled. The + /// resulting context **borrows** `instance` and `device`: dropping it frees + /// neither, so the caller must keep both alive for at least as long as the + /// context and anything decoded with it. + pub fn build_from_existing_decode( + self, + entry: ash::Entry, + instance: ash::Instance, + physical_device: vk::PhysicalDevice, + device: ash::Device, + ) -> Result { + VideoContext::from_existing_decode( + self.required_decode_codecs, + entry, + instance, + physical_device, + device, + ) + } +} + +/// Queue families and extensions a caller's device must provide to decode. +/// +/// Returned by [`VideoContextBuilder::decode_device_requirements`]. +#[derive(Debug, Clone)] +pub struct DeviceRequirements { + /// Queue families pixelforge needs a queue created for. Merge these with + /// your own (deduplicated) when building the device. + pub queue_families: Vec, + /// Device extensions pixelforge needs enabled. Merge with your own. + pub extensions: Vec<&'static std::ffi::CStr>, +} + +/// The queue families pixelforge selects for decoding on a given device. +struct DecodeQueueFamilies { + decode: u32, + transfer: u32, + compute: u32, +} + +impl DecodeQueueFamilies { + /// The distinct families, in a stable order. + fn unique(&self) -> Vec { + let mut out = vec![self.decode]; + for f in [self.transfer, self.compute] { + if !out.contains(&f) { + out.push(f); + } + } + out + } +} + +/// Select the decode / transfer / compute queue families on `physical_device`, +/// failing if it cannot decode the required codecs. +/// +/// Mirrors the selection [`VideoContext::new`] does inline, but scoped to the +/// decode path: a video-decode family, a transfer family (preferring a +/// dedicated engine over one that also does video — see the scoring), and any +/// compute family. +fn find_decode_queue_families( + entry: &ash::Entry, + instance: &ash::Instance, + physical_device: vk::PhysicalDevice, + decode_codecs: &[Codec], +) -> Result { + let queue_families = + unsafe { instance.get_physical_device_queue_family_properties(physical_device) }; + + let mut decode = None; + let mut transfer = u32::MAX; + let mut transfer_score = -1i32; + let mut compute = u32::MAX; + + for (idx, props) in queue_families.iter().enumerate() { + let idx = idx as u32; + let flags = props.queue_flags; + + if flags.contains(vk::QueueFlags::VIDEO_DECODE_KHR) { + decode = Some(idx); + } + if flags.contains(vk::QueueFlags::TRANSFER) { + let is_video = flags + .intersects(vk::QueueFlags::VIDEO_ENCODE_KHR | vk::QueueFlags::VIDEO_DECODE_KHR); + let score = if is_video { + 0 + } else if flags.contains(vk::QueueFlags::GRAPHICS) { + 1 + } else if flags.contains(vk::QueueFlags::COMPUTE) { + 2 + } else { + 3 + }; + if score > transfer_score { + transfer_score = score; + transfer = idx; + } + } + if flags.contains(vk::QueueFlags::COMPUTE) && compute == u32::MAX { + compute = idx; + } + } + + let decode = decode.ok_or_else(|| { + PixelForgeError::NoSuitableDevice( + "Physical device has no video decode queue family".to_string(), + ) + })?; + if transfer == u32::MAX { + return Err(PixelForgeError::NoSuitableDevice( + "Physical device has no transfer queue family".to_string(), + )); + } + if compute == u32::MAX { + return Err(PixelForgeError::NoSuitableDevice( + "Physical device has no compute queue family".to_string(), + )); + } + + // Confirm the device actually decodes the codecs asked for. + let available = query_decode_codecs(entry, instance, physical_device); + for codec in decode_codecs { + if !available.contains(codec) { + return Err(PixelForgeError::CodecNotSupported(format!( + "Physical device does not support decoding {:?}", + codec + ))); + } + } + + Ok(DecodeQueueFamilies { + decode, + transfer, + compute, + }) +} + +/// Device extensions required to decode the given codecs. +fn decode_extension_names(decode_codecs: &[Codec]) -> Vec<&'static std::ffi::CStr> { + let mut names = vec![ + ash::khr::video_queue::NAME, + ash::khr::video_decode_queue::NAME, + ash::khr::synchronization2::NAME, + ]; + if decode_codecs.contains(&Codec::H264) { + names.push(ash::khr::video_decode_h264::NAME); + } + names +} + +/// The decode codecs `physical_device` supports. +fn query_decode_codecs( + entry: &ash::Entry, + instance: &ash::Instance, + physical_device: vk::PhysicalDevice, +) -> Vec { + let mut codecs = Vec::new(); + if VideoContext::check_h264_decode_support(entry, instance, physical_device) { + codecs.push(Codec::H264); + } + codecs } /// Inner struct holding the actual Vulkan resources. @@ -71,6 +276,8 @@ struct VideoContextInner { device: ash::Device, video_encode_queue_family: Option, video_encode_queue: Option, + video_decode_queue_family: Option, + video_decode_queue: Option, transfer_queue_family: u32, transfer_queue: vk::Queue, compute_queue_family: u32, @@ -78,14 +285,23 @@ struct VideoContextInner { memory_properties: vk::PhysicalDeviceMemoryProperties, device_properties: vk::PhysicalDeviceProperties, supported_encode_codecs: Vec, + supported_decode_codecs: Vec, has_descriptor_buffer: bool, + /// Whether this context created (and therefore must destroy) the device and + /// instance. A context adopted from a caller's device via + /// [`VideoContext::from_existing_decode`] borrows them and destroys neither. + owns_device: bool, } impl Drop for VideoContextInner { fn drop(&mut self) { - unsafe { - self.device.destroy_device(None); - self.instance.destroy_instance(None); + // Only tear down the device/instance we created ourselves. An adopted + // device is owned by the caller and outlives this context. + if self.owns_device { + unsafe { + self.device.destroy_device(None); + self.instance.destroy_instance(None); + } } } } @@ -118,6 +334,14 @@ impl VideoContext { self.inner.video_encode_queue } + pub(crate) fn video_decode_queue_family(&self) -> Option { + self.inner.video_decode_queue_family + } + + pub(crate) fn video_decode_queue(&self) -> Option { + self.inner.video_decode_queue + } + /// Get the transfer queue family index. pub fn transfer_queue_family(&self) -> u32 { self.inner.transfer_queue_family @@ -241,9 +465,11 @@ impl VideoContext { let mut selected_device = None; let mut video_encode_queue_family = None; + let mut video_decode_queue_family = None; let mut transfer_queue_family = u32::MAX; let mut compute_queue_family = u32::MAX; let mut supported_encode_codecs = Vec::new(); + let mut supported_decode_codecs = Vec::new(); let mut has_descriptor_buffer_ext = false; for physical_device in physical_devices { @@ -258,7 +484,9 @@ impl VideoContext { // Find queue families. let mut encode_queue = None; + let mut decode_queue = None; let mut transfer_q = u32::MAX; + let mut transfer_score = -1i32; let mut compute_q = u32::MAX; for (idx, props) in queue_families.iter().enumerate() { @@ -266,20 +494,50 @@ impl VideoContext { "Queue family {}: flags={:?}, count={}", idx, props.queue_flags, props.queue_count ); + let flags = props.queue_flags; // Check for video encode queue. - if props.queue_flags.contains(vk::QueueFlags::VIDEO_ENCODE_KHR) { + if flags.contains(vk::QueueFlags::VIDEO_ENCODE_KHR) { encode_queue = Some(idx as u32); debug!("Found video encode queue at family {}", idx); } - // Check for transfer queue. - if props.queue_flags.contains(vk::QueueFlags::TRANSFER) { - transfer_q = idx as u32; + // Check for video decode queue. + if flags.contains(vk::QueueFlags::VIDEO_DECODE_KHR) { + decode_queue = Some(idx as u32); + debug!("Found video decode queue at family {}", idx); + } + + // Pick the transfer queue by preference, not by last-wins. The + // readback copy runs here, so a family that also does video + // (encode or decode) is the worst choice: it contends with our + // own encode/decode work and, on some drivers, shares a single + // VkQueue that would then need external synchronization. Among + // the rest, prefer the least capable family: a dedicated DMA + // engine first, then compute+transfer, and only then the + // graphics queue (which a downstream app typically drives for + // rendering and present). + if flags.contains(vk::QueueFlags::TRANSFER) { + let is_video = flags.intersects( + vk::QueueFlags::VIDEO_ENCODE_KHR | vk::QueueFlags::VIDEO_DECODE_KHR, + ); + let score = if is_video { + 0 // shares a video engine; last resort + } else if flags.contains(vk::QueueFlags::GRAPHICS) { + 1 // the universal graphics queue + } else if flags.contains(vk::QueueFlags::COMPUTE) { + 2 // compute + transfer + } else { + 3 // dedicated transfer engine + }; + if score > transfer_score { + transfer_score = score; + transfer_q = idx as u32; + } } // Check for compute queue (prefer dedicated compute, otherwise graphics+compute). - if props.queue_flags.contains(vk::QueueFlags::COMPUTE) && compute_q == u32::MAX { + if flags.contains(vk::QueueFlags::COMPUTE) && compute_q == u32::MAX { compute_q = idx as u32; debug!("Found compute queue at family {}", idx); } @@ -334,19 +592,46 @@ impl VideoContext { } } - // Check if all required encode codecs are supported. + // Check codec support for decoding. + let mut decode_codecs = Vec::new(); + if decode_queue.is_some() { + let available_extensions = + unsafe { instance.enumerate_device_extension_properties(physical_device) } + .unwrap_or_default(); + let has_extension = |name: &std::ffi::CStr| -> bool { + available_extensions.iter().any(|ext| { + let ext_name = + unsafe { std::ffi::CStr::from_ptr(ext.extension_name.as_ptr()) }; + ext_name == name + }) + }; + + if has_extension(ash::khr::video_decode_h264::NAME) + && Self::check_h264_decode_support(&entry, &instance, physical_device) + { + decode_codecs.push(Codec::H264); + debug!("Device {} supports H.264 decode", device_name); + } + } + + // Check if all required encode/decode codecs are supported. let encode_supported = builder .required_encode_codecs .iter() .all(|codec| encode_codecs.contains(codec)); + let decode_supported = builder + .required_decode_codecs + .iter() + .all(|codec| decode_codecs.contains(codec)); - // We need encode support and compute support. - let has_video_support = encode_queue.is_some(); + // We need at least one video queue, and compute support. + let has_video_support = encode_queue.is_some() || decode_queue.is_some(); let has_compute_support = compute_q != u32::MAX; - if has_video_support && encode_supported && has_compute_support { + if has_video_support && encode_supported && decode_supported && has_compute_support { selected_device = Some(physical_device); video_encode_queue_family = encode_queue; + video_decode_queue_family = decode_queue; transfer_queue_family = if transfer_q != u32::MAX { transfer_q } else { @@ -354,22 +639,34 @@ impl VideoContext { }; compute_queue_family = compute_q; supported_encode_codecs = encode_codecs; + supported_decode_codecs = decode_codecs; info!("Selected device: {}", device_name); break; } else { warn!( - "Device {} skipped: video_support={}, encode_supported={}, compute_support={}", - device_name, has_video_support, encode_supported, has_compute_support + "Device {} skipped: video_support={}, encode_supported={}, decode_supported={}, compute_support={}", + device_name, + has_video_support, + encode_supported, + decode_supported, + has_compute_support ); if !has_video_support { warn!(" - No queue with VIDEO_ENCODE_KHR flag found"); } if !encode_supported { warn!( - " - Required codecs not supported: {:?}", + " - Required encode codecs not supported: {:?}", builder.required_encode_codecs ); - warn!(" - Available codecs: {:?}", encode_codecs); + warn!(" - Available encode codecs: {:?}", encode_codecs); + } + if !decode_supported { + warn!( + " - Required decode codecs not supported: {:?}", + builder.required_decode_codecs + ); + warn!(" - Available decode codecs: {:?}", decode_codecs); } } } @@ -393,6 +690,11 @@ impl VideoContext { if let Some(encode_family) = video_encode_queue_family { unique_families.push(encode_family); } + if let Some(decode_family) = video_decode_queue_family + && !unique_families.contains(&decode_family) + { + unique_families.push(decode_family); + } if !unique_families.contains(&transfer_queue_family) { unique_families.push(transfer_queue_family); } @@ -442,6 +744,13 @@ impl VideoContext { push_ext(ash::khr::video_encode_av1::NAME.as_ptr()); } } + if video_decode_queue_family.is_some() && !supported_decode_codecs.is_empty() { + push_ext(ash::khr::video_decode_queue::NAME.as_ptr()); + + if supported_decode_codecs.contains(&Codec::H264) { + push_ext(ash::khr::video_decode_h264::NAME.as_ptr()); + } + } // Enable VK_EXT_descriptor_buffer extension (required for descriptor buffer API). if has_descriptor_buffer_ext { @@ -558,12 +867,17 @@ impl VideoContext { // Get queues. let video_encode_queue = video_encode_queue_family.map(|family| unsafe { device.get_device_queue(family, 0) }); + let video_decode_queue = + video_decode_queue_family.map(|family| unsafe { device.get_device_queue(family, 0) }); let transfer_queue = unsafe { device.get_device_queue(transfer_queue_family, 0) }; let compute_queue = unsafe { device.get_device_queue(compute_queue_family, 0) }; if let Some(family) = video_encode_queue_family { info!("Video encode queue family: {}", family); } + if let Some(family) = video_decode_queue_family { + info!("Video decode queue family: {}", family); + } info!("Transfer queue family: {}", transfer_queue_family); info!("Compute queue family: {}", compute_queue_family); info!("Created Vulkan device with video support"); @@ -576,6 +890,8 @@ impl VideoContext { device, video_encode_queue_family, video_encode_queue, + video_decode_queue_family, + video_decode_queue, transfer_queue_family, transfer_queue, compute_queue_family, @@ -583,7 +899,68 @@ impl VideoContext { memory_properties, device_properties, supported_encode_codecs, + supported_decode_codecs, has_descriptor_buffer, + owns_device: true, + }), + }) + } + + /// Adopt a caller-created device for decoding. + /// + /// See [`VideoContextBuilder::build_from_existing_decode`], the public entry + /// point. The returned context borrows `instance` and `device` and destroys + /// neither on drop. + fn from_existing_decode( + required_decode_codecs: Vec, + entry: ash::Entry, + instance: ash::Instance, + physical_device: vk::PhysicalDevice, + device: ash::Device, + ) -> Result { + let families = find_decode_queue_families( + &entry, + &instance, + physical_device, + &required_decode_codecs, + )?; + + let device_properties = unsafe { instance.get_physical_device_properties(physical_device) }; + let memory_properties = + unsafe { instance.get_physical_device_memory_properties(physical_device) }; + + // The caller created a queue for each family (from device_requirements). + let video_decode_queue = unsafe { device.get_device_queue(families.decode, 0) }; + let transfer_queue = unsafe { device.get_device_queue(families.transfer, 0) }; + let compute_queue = unsafe { device.get_device_queue(families.compute, 0) }; + + let supported_decode_codecs = query_decode_codecs(&entry, &instance, physical_device); + + info!( + "Adopted caller device for decode: decode family {}, transfer family {}, compute family {}", + families.decode, families.transfer, families.compute + ); + + Ok(VideoContext { + inner: std::sync::Arc::new(VideoContextInner { + entry, + instance, + physical_device, + device, + video_encode_queue_family: None, + video_encode_queue: None, + video_decode_queue_family: Some(families.decode), + video_decode_queue: Some(video_decode_queue), + transfer_queue_family: families.transfer, + transfer_queue, + compute_queue_family: families.compute, + compute_queue, + memory_properties, + device_properties, + supported_encode_codecs: Vec::new(), + supported_decode_codecs, + has_descriptor_buffer: false, + owns_device: false, }), }) } @@ -765,10 +1142,71 @@ impl VideoContext { } /// Check if a codec is supported for encoding. + fn check_h264_decode_support( + entry: &ash::Entry, + instance: &ash::Instance, + physical_device: vk::PhysicalDevice, + ) -> bool { + let video_queue = ash::khr::video_queue::Instance::load(entry, instance); + + // H.264 decode profile: High profile, progressive, 8-bit 4:2:0. + let mut h264_profile = vk::VideoDecodeH264ProfileInfoKHR::default() + .std_profile_idc( + ash::vk::native::StdVideoH264ProfileIdc_STD_VIDEO_H264_PROFILE_IDC_HIGH, + ) + .picture_layout(vk::VideoDecodeH264PictureLayoutFlagsKHR::PROGRESSIVE); + + let profile_info = vk::VideoProfileInfoKHR::default() + .video_codec_operation(vk::VideoCodecOperationFlagsKHR::DECODE_H264) + .chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420) + .luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8) + .chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8) + .push(&mut h264_profile); + + let mut h264_capabilities = vk::VideoDecodeH264CapabilitiesKHR::default(); + let mut decode_capabilities = vk::VideoDecodeCapabilitiesKHR::default(); + let mut capabilities = vk::VideoCapabilitiesKHR::default() + .push(&mut h264_capabilities) + .push(&mut decode_capabilities); + + let result = unsafe { + (video_queue.fp().get_physical_device_video_capabilities_khr)( + physical_device, + &profile_info, + &mut capabilities, + ) + }; + + match result { + vk::Result::SUCCESS => { + debug!( + "H.264 decode supported: max {}x{}, {} DPB slots", + capabilities.max_coded_extent.width, + capabilities.max_coded_extent.height, + capabilities.max_dpb_slots + ); + true + } + vk::Result::ERROR_VIDEO_PROFILE_CODEC_NOT_SUPPORTED_KHR => { + debug!("H.264 decode not supported on this device"); + false + } + _ => { + warn!("Failed to query H.264 decode capabilities: {:?}", result); + false + } + } + } + pub fn supports_encode(&self, codec: Codec) -> bool { self.inner.supported_encode_codecs.contains(&codec) } + /// Check if the selected device supports decoding the given codec. + pub fn supports_decode(&self, codec: Codec) -> bool { + self.inner.supported_decode_codecs.contains(&codec) + } + /// Get the Vulkan entry point. pub fn entry(&self) -> &ash::Entry { &self.inner.entry diff --git a/tests/context.rs b/tests/context.rs new file mode 100644 index 0000000..0f39361 --- /dev/null +++ b/tests/context.rs @@ -0,0 +1,44 @@ +//! Verifies device selection fails cleanly (not by panic) when the device has +//! no video decode support. Runs against lavapipe, which has no video queues. +use pixelforge::encoder::Codec; + +#[test] +fn require_decode_fails_cleanly_without_video_support() { + let result = pixelforge::vulkan::VideoContextBuilder::new() + .app_name("pixelforge-test") + .require_decode(Codec::H264) + .build(); + match result { + Ok(ctx) => { + // If a real video-capable GPU is present, the contract must hold. + assert!(ctx.supports_decode(Codec::H264)); + } + Err(e) => { + // Expected on CPU/drivers: a typed error, not a panic or hang. + let msg = e.to_string(); + assert!( + msg.contains("No device with required video support") || msg.contains("suitable"), + "expected a NoSuitableDevice error, got: {msg}" + ); + } + } +} + +#[test] +fn context_creation_reaches_device_enumeration() { + // Guards against the test above passing for the wrong reason (e.g. the + // Vulkan loader itself failing). The instance must be created and physical + // devices enumerated before any device-support verdict is possible. + let err = pixelforge::vulkan::VideoContextBuilder::new() + .app_name("pixelforge-test") + .require_decode(pixelforge::encoder::Codec::H264) + .build() + .err(); + if let Some(e) = err { + let msg = e.to_string(); + assert!( + !msg.contains("Failed to create Vulkan instance"), + "Vulkan loader unavailable; the decode-support test would be vacuous: {msg}" + ); + } +} diff --git a/tests/data/base.264 b/tests/data/base.264 new file mode 100644 index 0000000000000000000000000000000000000000..0f52aaf5a3a7767ecba9eba1f46b0b397837e605 GIT binary patch literal 9956 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 literal 0 HcmV?d00001 diff --git a/tests/data/bframes.264 b/tests/data/bframes.264 new file mode 100644 index 0000000000000000000000000000000000000000..fe8b29ebe6652a8b378d30f677197ebbacc29d23 GIT binary patch literal 7251 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 literal 0 HcmV?d00001 diff --git a/tests/data/zerolatency.264 b/tests/data/zerolatency.264 new file mode 100644 index 0000000000000000000000000000000000000000..b0997f2d11e8b637ee1cc06f26e0c8172dbcff55 GIT binary patch literal 11485 zcmZvibzEFK*Y5`}?rwt?_u>N-cP*|14DL|e-3obDx`pi3YC00Ve^AjGYQ$N*kTEDpK{DLY;bc(|7r z{Y8nr@7wdzjfqTiz6Rc0Mz{*8*vlR|i%F zWeF)}F5r6&u~$!1h{=M8kG+G7rM;aqKhOkh3^oC=1DzmdAWonu#Ms8(#2UoT&&JON z1lxgaJe(mQHg`^bHg|S*PM|FWWC3vpI)5~NwRnII&K|E-U!R6frXY3}w%1Cp51_53 zJH*uRuNK)~YZy9#?aUz{c5a}Fg_FH4*zmO~JJ7`mVq;_J{A%&K^O~Btyc#Bswjj3G zA%IOi?d>2S4t6GXcAy#9*~QSo+1k?KFONSBI64^Go0&O7TtLhmKo<+A*BXB{WMgk{ z4Yqhq82)dbvyG+6>sbCLgAHis^q)RVEN#Iqe~rV^&IRIR1AcXS%{R9B=mhpKG_kjJ z0K2@VOeB&)pYo)q5u4X-9g-ZuNlrR5C;$!(9+?R>+2l8Vt;KL?D#rme@y_u|24)S;lZ%& z&o|{O0SeuW@2tNy0%*N}{n%m@u~Otb8xMqo_)k2nehh6z+d@lq^1UxiQzdu77LE9y zUu3Eb;cJjJ1@9BmpIP$@L%5kFJ|hWbi2%ttnxWe2f8Nt}Ew3LUtn$*xB|ynfrtDwI zJ!X9$anoAh`Z8&q{nXJSHF43t)9J_{_9i~Im4EyhsS8*EWmBJ?@J;U`S>J$5d-`*K z5O>Z@@s8^eqaW_z%Cb_m_@EH*on;Bl&D-Uj%i$krN1A1MYp!-pW#i5!XiZG>%Od#e z3XaOV4-SuV$iX(q}?6PINrmY-plRWW)7mYTet%BTH8?Yb97IlBywe!W z$%?K$p=c~io%E7<5NDav%?M8dpKz~YA*$@dca5AGVm`5>T-Kp5xlj>oOer4e71l^o zqu9S78Y~*1gL!#AOg~RlKWEmt=7W2%YPBUFoxGv-VXAD@G+jpSm#}i)kk(wf#8DpE zx(;u)&Xlkk3YBciS7<!w0gI7qWaPc{% zz*Cfat6wF!cX1_S$o9!f4!4k@VqG8Sg z16m~x(JVoNv#a=Vd3Ncz){*MMLem1J62T?2AEO)v*|6SXwb!ORu05Aewi18%xYXgw z9L~-~VD*qsWNorZr#1Y96En7>Ao98+n4qAY?ekRDydpPtF9b^S|Ld z=VbyUtu}DtXAitIT4Lt6aDC0oOchia-)MTu#f-=$A36(7Zs*mBw>T}Q*k4ywN_6kd zfp-7{QC8%}(WJumi~_wspf-As67yS^3HMXwpPD{P7ZXwhH)fdB7vFj=2 zNGBim{T(XXy*+^Ro}XccqkqJ-dHWDC5QcT(do$up?aYv^^@zIr@!YDrNy#nMIu{8C zn{te%M{%gQ@n3Kp-$frx3J1-4SpsFkp%A&;rVDg8?-7_ND(=hSy(`ds^!jAOLLytd z6R!+<^fL(bKO@w~exlrnZ{{rts-BzIkbhI4Hx5ZOVKvCzhbH7^RYSFMo)B<~PT?U7 z`f{x~n#}$lzT#Q(3T<*^rT|`sm95sU8Z{|ttO~qS8k(v6z#f-wR5EK^dB&Mz)`ICA zj7Ten^B@TBJq|_!0Z|5z7q7La4&~oTxuAb3^%M~~ODxSTcjH$+i{EUK1-b zka0Gd+(EZR7GH%yoyI#@E!24vhC)vviaP_2!MB0r)VbUnPw3NO{P5-3G3stc#52f??cnFThZp017wnzABT(kkLH@%TBq-q z-r$AKvk%sN?lHT}*n1oD)%8VBe$NPaT7{eB7;Koe zH-^FZ#>)t#CHV^>O#A2QfsST{OqUuP-U<7l3XCVM&T&m9KOf1sKRF5-AI-10&Z}Uk zHop-JaI%!k2(I9SEN3|F&{Dd!CUY^n0bWRhxTJS2Z9zpqzEZuvOf(VXTz)d29pj6nNblu#lp2X3BIZ*_VVwZh89;-<4a(y zptC!W(mn=zC6+3`RCx-N)>2x-+MtC0STIutOWJsy6|_}zCr$R3TaMQ0RaZN z2qc@NGBtdv)ugw#opUERySjF=4 zaomU7rpoD1JsA2NOBwF6dI#Tnu)2$)P1=_yr2ZHU&ra|AYbO^?s^DCP=hM6~eK}`O z@2;sT@zL7z_bYR$g$S|gm$B<|{`T)V?ZpC>C>TEh8AS6Xj1gStm-Q;;vgExvsryCS zMn;uv+6(ptn$ElBL%L`YI?-aolek0v^-UN<*58ezLO7Gm5+J4RmgfEsIs4=qH7Q(3 zlI&lceE1IqXCJwJ=N*zO7tJ@1kak0sY?tut(7sW+Ag+^6@eQ=(qptmgy?7gUmFu#W z-b0l}ivG*Fcf%o*ST(@q{v*k0@*#v?nRc87dKeFS@K?SX^i(W`zY+fnnktZ1?S(%h z8Cy?DrZwZVQ(F6`&YMxr_0ou_x+v0c+4REUohNlCa)Tl(6PCruN41uGZMU>A%}Q;!IqXg^%?k}x^Y;IQAk1SqlC z;|V=vUC)-H84l_=*rueym!*1&gK6(Yh3v7mR;~y&ePSN6&UjGzZ=aIlL_99oj>{$- zRc6WpIS#ccztZDqH=9?eO$I{Q#-0>Jmbls;JU^=Tj6sE-4DNnmbwrhs{m`*mD`72s zK9Tet&lQ!pS<;(&;s|@g8!PSjXBQJ&(kF*Pb*B>x6LdXcG?t8}&w7}!tKKM$-ThL_r>X4=!x)Dd;YQ@nT+F%7gHQ@5Ib0s=HxCrUl zp-(eJf3z(MExCl^xU%}kFLpfRE}iWRwof-VX!P+~_XF-)sB!6T@&WAx*^gqGDH^`W zD)*5l{FZWHkHA^58}TE&U``}g9B zG_I`#-Y@mG9fVe7?p0+u(Ub2u&saB{Tar)mP`LzMP#5)^M&N)%M9K-0yWum$lo(2> zk&47{(faahN7vFtfuEiy=di!@l9#Y{VGk_`-1T65#`1B|9E7!L2roXX9Nr4j*B)RA;<+fzcK$CqM zuxb#DI7Tx#DLuFy%_p~dAw>~TX_F%ZWsr)Mj&f+4m;3US2qyabEPMHrM0{7%Ry1}; z^B<1mUV_88Ro)wD1{Xv_A?J?LmE_mFY>KpGbQ^ZD$8H|CwiCKWyBZg$!x7oi=xd8f zwR0TR3R*Z7B2zM$Yca%(#!yTj1bCbUK<^Q1Hr}O34j-Kd?OG9E!kwC0st^x9y3&qJhCh!jF{qrp zjPV&oK1;lUV1pQX;fJqrm>rY2YjVSxy{OyC^FaT)Zg9xu+G5Zp-8PR~&D;l`ccSw7 zQfx9W^wqS_PyO${2~~|Wcs=}3os-aLjpNUeb$2b!!A-G9cZ^u}}m3@iLx zstzCvX2}LD6sQ;?ya8^Us7SSBGsUaQ>|*x4`R2tI>p_V4-K&aU(qajd#(Y;z1?>q_|N9 zd1lRYQfPOoDxA6*MV&m>E%{;MzGuoMDRDe3d%^Y=PYN~mLG-xc`L{e|kM;a@yc-@O zWI`%mzz@T|(n^BsE|pAd=GY4qR|*Y{Z$^(^%w}|a$(;}hLjwLVaB>&xPv{6Ve6DBk zHw_C@255QMP7`nZJindVanP(9UzZ69QLC4C*?&hf?*ok*B@H)H%H%yfp2O^IpeGmi zT{a$hroiM23fA4$OK$rcKBM-_#`y|N^O6p|t@J*auAY+v;RDeUUpgHow+T(?$}-d^ zR01DE7(CekIH6+`bd~+G&Mxj5vP`OlEMSJJ0gT~s^QjuX$;BIe()?ob2^0VA*wYjm z-sA^m(zD&vHx$M9_7ZhcN0DeH+1dv)7IEDI;j4{$z8ps}PQOl+Xs&Vm)Zi{-CIKu5 zW9?E>h(U_lNL<{T7Of`Hk3lv&Z37tf3y02`PA8X;b#sxL(Tqc+Ve5;IKQQ9|FASzQ z{*I2JDxE}z&H4?r>iLYDw*Fj z!vi(Pnp3m)8IqL>oT~>>ax|!|U-U{LC8~jE?Clj;lWfDl_{gb>J$dxtZbBPOs`|g~ zmt>H=zxGDU8;MrDH3nkT!6nvwAXylRTN@znZH@4 zL-gskosLOhRCB?HX*kV62F(9X2(8Ncp{nO#qY;(cH)s1FK8YFF64p5m*uLjiM#i zMx|VY!P$ZZm=89C=9h&;Hp(8BJMWTtgYrmKfZ}Ec11kf2^0M=M&t2KYmJ-TtIXvzF zw9yZpCfR3AaI;{{dFQQWM&`1p;*9YLglzaSLGBgH(db`eorK07pXUb{`@!UMPvZPx zc?>Y#a}Z{Oca;U3>`)kT%HNP=@}~RXV}>g%Z%b&zY!_sS$w+b3=&=#eLnYZNGEF{o z%ZU{_ZW`tgQkhhLQ4x)Cn2>_bi&N4ggvxwon%=RiWgs>CJz2ki4k@{YXRy#*(P8yD zlh{nkQ>?~M>(>%P+hxnxB|;!6SfD{KoEvtDHWU4@E{-0!>>w;8$R_+ZqKW@SB3cHL6Q?J{xd^X-mmqO2$6QZFLtsqGZo+6{F2)fe6p!3yVkSec~ zo!!mWHh-zE#H|)&ELPX-5f>N0NDYqIxAO18hj==gnSc%W7pAbBtqbW;v)>GVdAi7o zb3ByjXO)BP_Sn4P>ZU=k|A}^d-Nj)$2Z&>FK0GIK{a(Pg{f;D-rRD85gcAGge9>U% zEI?P;!$CD+~{rO6y|woq0d9xcBF+TMa8!&LfR>%)PRKVxE{y?QNTwYN80S~mg`lXM(qQpF>n zoDI{}I4Uds9 zPK!uHp#(PT*IFQ3?P_6sk5biMK=Ziwv51SpS&G!;@t@1!>}F>OmJGtONP7nk$n9SX zHZr`id>#+N`+{v9)| zbVb3?=LXk~TV=!(_$^$U6}hZVY?tFQ>lv>mWZkl@Fz8K=drY!@q+AvmT=9&h^7B;P-E)ZC(jk ztj*ALT%yA5+NACRp(g3ZkUZR^_hwn2NSG-OV#0!KpKd z8U`O&F|ien7~lI%Z6Fv3f3}e@dmKWl z@hE(<%$7o1vo4-j-@sm7>m$aXO)0*el&=WS4C5&CoOO zjMKiYF+_#00WSAriKFxK>Xh&EYRp4Z3ra}^baws$;q(s>e{X9{4yypvf-Ro6l*p_a zHTD(h;WTh4+QGpPM3n2DCEGOm2;adv>*a3K~?ycJ;c z82R!k!}o1)Rr)1Aaeg1$hD7Vm4`wt*Xk)gpJo?G(G|Ef{$?z?#mGTb~xWdVu>vRIx>+5+moJoHO^%n>mk9$?@3U)2|P?=0b zb;d!d<{asSg<>eYH_q_sn!tt!6F2m;k~Rsea==J{Xv@vd?`e(o{d?7#k(mOy^yYsd z0V?@l_@KOp&F?SNg_CzxW!?zPVP2%Cg?G}qynhnICitaVNNw{&GnQZwRlHjFcnrr7 z+8BF?#yI4W9vq^$@>Q8QiNS5ckJ5d|9PUQ9x`5H4DwUhFv#KlOlW!G_(3T0!fq1`F zoLAK|Q<^qRZhes%WzlXr-k0aO^zHl+2ryA7{MXJKidFovAF~WIxBa!*AcGri%s<>} z{=p4!$rbJoaC-7VywIT89UeIm!=N0gNvU-h9+j&0q*BQz6^nk#pqehE8}^v2M0lU_ zejSN$@go!dbW(poBoYKwHNb~WN~3tJc};#h(b}~2!n3l|$nxjB<@Ay}RYU??0yT-| zuKtl}L}0;ofP$BSUU5&y?zzwV63<{HyB?{*Y&;6k5r>c5V9`=i+*ZOiaOAFoxrMPeHqKsIW_FUtJ}FqdBz zC+2Yi|6UXm|I|q0HVkV8fX1jO^Qu>BeC`3{tfpe<{lJU;-IV)mKH|r~a8OH4Iz1pk zulUhR<#bY=3I^_wAtu`Pai40wj=|z5x9xcT_1T+xw7xaC-#ZfGrh|f|kARrFz$gi5 zK^74z7H)5a37FD`LR+}1xloFvDT`vsxlgb`YMSXpw#Qcd$D}dTA^DFj*f5>4 z1w8_5%})J^U#Oq*M>E%Ch>O0st>d`DYNUcf|!$~I< zfQMVM`3>WF@hFa|mK10+GD(Q3?^B*~X(YX(^A#n7-=%e`nLS2lPRZC-H8v&^`F%MpAz~G6!#;9|M4?^|*&j;7hX20CiW!TCF-Og#~M}9b$v-4E=OU z0x1!?9NQQP3Sx!j94&RT^yg64#G;$_3O_+bM@QKtSW#^udWa}ppc_&6!VjeqOQ2^6 zv=(XqLL42J52ew;FHpOxvQOBNVc7oIvvCko^bm*LZ^)OGDOR*Pz(n|{3hkzXZufLG zvd`Gvf>i%DtomUbQoJ#iyQ2McVrsCurCjlrf+PmV?6>4W%FRJO>HP0D8ec#ICVi5W z28`x03BlLzTIfj0ky+i98Bn^XAk6YUprgib$do0yle7z1r8>H2QE?qak0IG-8N#eY z=Uy7%%>q+>{(9T^03+}GG@Z<7%4Xpk-F_^LoiG{jpB0jd^>0*T@4tp=conb#_F)}2 z$($ZThUfU9-pra1^u_d@59r!Nb1}7(H0QKxOOB&M{n91uV30Am%Lx~+>K|II_clv& zi-O7jL0^9iZ&KQ3|hxQ0*hW#-MZ} zhI?e5`^?2|$8G0E&oDw99)Z{*3oeU}cfbnmNvLNzyoAQF|D`?_scgp^Ai(ozOpkEjGdZHQJL=vtCmB0}>)o&E53EVZEM{wo)0|Qh3+l0=&U6fq>G%dP zJ%TE{+uU^O2MCCEhryi}_=H2}sU+{!@nztxtF(VLaq? zHalD!Z9dfT;lbGpMYCa#0>4}g^xMpUwz4E8Lw}AVZuV}!t2+4`ix~S5z^9jo>zzQ zva0JmgY>IEQYXoCio)uBT%cn_QuPp*)Oo&CsI?O7AvQMXQn0Dd;x<>_LQel@|4-5! zzFg{`0zVc~4f$d}tsF!mRPl7$eJ#@WBeRId4*;4btrJ64kBDFzwc)QS{d|u7v#q8& z{af=eY(|FG#ige^Z<;^gt*PJ~U5VIfbi@@c#tvNKIQ61HY448D=j?^^xx@DBar`G0 zmO`jh!s7C(@I`N46O0r2n9cxZ!2+x1Hi|}_Pq`e#@vQ1+Ws?fc*qZDL#_Cp*)eUCoxrT_&@PTYZ)WRto{js_S^M znk3or8C2{{^bn*-;N!Gmf_NX9!crysQd&o6ses|a0##0lurtgzk-n~**$m!@_eB+P zsuhwzsWgMx)lY@otPWCvGvGB~oktr8cD!W*RH-#O|vDfTYhvXfKP!e$4s>MQ$++bpkE5KALijkhNKk zJNfKWkE`jXc_v4{CHUJwGTaB2;#55}VRBc!yMnH*VNnfRXbCPJ*=;Zs(G`+*^Udr7Tez{D|REb@tls7W^xsUb(yQyl8So0WJ zrQV|pbTJ-g?dg8g_sR`PXj3Fu7y$cWbU2*=4{93YJGQS*c{P__eAsG{3Y`7gD>Y;B zw$4+dj9bu=s;$%A&Vc+siT_?06aQl4_8JK>*wZMd0FrxzRzVi;?w4{yzJqml@NmzS zMM62_n5+9bY$=V1gjr_uqhoxYn##azCUs{f_VP_7fvwLseF&+sxcL&UtI9{U?gp51 zkj(g@)Zw@@2&dtjz3uRTX~;GO+iEnV?fthlTx`$dYC2V3fm*GjR`YpE7N`T6&-p67 z5rexmL>iFT`gTZ&rAb;bU7o85J&&;NL%i!pZ9>M!=+&l=cOe9xt1GYn9RVMXE%Aw< z{zkM!;Ki44tU8%mB;F+aV*LF({SDTy@X}|thMhMWIAZ}CVBhCE7~=rktyP52e^$Zy z|EoP&h68}0n5GtRoLUg#O=PZmyPs?T6SYE|9Pj!)vJ_5vg1n)+MC2@O5fQhLz7X+d}#Pe*3ufNd)wQ`{VvsMAQ$WkZHR;l zNKYuQcEtK!XBbbF|2b_|n7P>>Z7Q%djyv~TH5!tv{?JMF=(RHQA8n{Q*A^$zzPkOg`tC@ArBF<%$#RW_(n;mds7tI_} zQMKW?df{}=$J2+2Q1+?QKPZc8d6#49)Sf)ontUo@%t6!QJa}_HvEPS@9}Qz`di6Sh z6Vbf^`}ho1@gPnpvG2V7xWS3#KGfouH?B$5vqN2(oGpP)*!AUNeQaLVk9-XY`yUYx z*polit>IJe8EV=|9zCjnF;FsaSnk`^+LS3Q2DnQBYDAli{-l!nT#dHlJ`H^|OLfAh!>y$#O?C+`Wa zixOLzIU0U+5ugeNdB?6>JJO3-9oJ}M+#plp3#va(8;Er1_wRMhp(x!|UHdc1h1%m4 zrFj+UqTE#yB`3}Jc<#&;p|hKS6>JvJB%HWYP6UbJ(^_-Xi~}iemL%z<58bIXC$m)y zHKgtih2&H(^+SsEf2_KuB&BR>JWvhAOON@2z6Pw^wxXGUF=%D<*p*i3+H$?hTIoZX zS*@qvr$R@^4uG6rbh4ncFOviD(ITsh5)UpXYsEt8)xlKtn6<^*1-lC>M+C>w(av+9 z6B&nM^C7qunEZ{zoAa~M?&hLHHg~MDKRc`NeVRexG*-xhnp9F!D*k_>M)hBqUQZ?& zF*t!5>3}!V=oNi7+C}#>qe)}8pyoO`Xv}kQ`a=$UH@e7($N{W`@?65Vz}7s9#xGNE zOSLFI@_C}}KJNH_;WZ|euITJ}i--|b#18JqjLAaB+n)`L3fcZBZP{Y3m}QChdX z^T7iWU0TXQ6LuUG$uPI40OLJ7Jy96c}-eLBZxsbTVeaz|x6Jhc0+Kfg(-qA<->5D&buCdxaMX7U z&2mXH8B&!(bOyDB0fK*U#Q!%AQ6~)?0C&=^3b{W#T1xQ9d^$gejqW0YR|N2|K1RBI z^vT7IrBQ^D;NYM&eH(0u^{vejnPfzTtK@5G1m@|UvHMf-bm8JV_5OnGU+y6bYRBL8 z`P`U`P#d2~rgI9Nm2II#DIV(kt{@};U^sy&y|;sH?_qo**5m(na3cLKiX^lt-a2Aq!$@k zwF5v2%j$MC;gaE0E1dp`1sc~`v^CZxt;<}UU7^~nmr0~w#sf=)_`II@u5&6|e;$|8 z;9%S0OG6*gdQH_+1>4<+l+vXx*y^TBC^J#gvdCHszYaQ;>gHW&-rhscw^yHj_TWD5 zHF%VRw^!nua7WN*gU)M2f`Zi&!rRPCa41JOuKUA$4}J%pT* zB0jdlFty;wR7K<LEfrWCSq4cbaN>6C75Hcd;jEPWQMgraC;Yv_GTL)) mzXCK&WI1+sG`BEYcsm6f