fix(encode/vulkan): unwind every open/import leak, and serve 24-bpp CPU instead of dying on it
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 12m42s
windows-host / package (push) Successful in 18m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m6s
docker / deploy-docs (push) Successful in 32s
docker / build-push-arm64cross (push) Successful in 6m26s
apple / swift (push) Successful in 6m42s
deb / build-publish-host (push) Failing after 5m39s
android / android (push) Failing after 6m36s
deb / build-publish-client-arm64 (push) Failing after 5m35s
deb / build-publish (push) Successful in 9m40s
ci / rust (push) Successful in 22m26s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 55s
ci / bench (push) Successful in 5m54s
ci / rust-arm64 (push) Successful in 11m18s
apple / screenshots (push) Successful in 27m18s
decky / build-publish (push) Successful in 23s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 12s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 10s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 9s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 9s
arch / build-publish (push) Successful in 12m42s
windows-host / package (push) Successful in 18m11s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 17m14s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 17m6s
docker / deploy-docs (push) Successful in 32s
docker / build-push-arm64cross (push) Successful in 6m26s
apple / swift (push) Successful in 6m42s
deb / build-publish-host (push) Failing after 5m39s
android / android (push) Failing after 6m36s
deb / build-publish-client-arm64 (push) Failing after 5m35s
deb / build-publish (push) Successful in 9m40s
ci / rust (push) Successful in 22m26s
ci / web (push) Successful in 52s
ci / docs-site (push) Successful in 55s
ci / bench (push) Successful in 5m54s
ci / rust-arm64 (push) Successful in 11m18s
apple / screenshots (push) Successful in 27m18s
Phase 5's Linux half (audit WP5.1 + WP5.4), each item shaped by the
review that rejected the obvious fix:
Dmabuf import unwind (vk_util): every failure after create_image leaked
the VkImage, and the dup'd dmabuf fd leaked as a raw i32. The sharp edge
is that a SUCCESSFUL vkAllocateMemory transfers fd ownership to Vulkan
(vkFreeMemory closes it), so the naive close-on-error is a double close
that clobbers whatever unrelated descriptor recycled the number. The dup
now lives in an OwnedFd released exactly in the allocate-success arm;
every other path drops it once, and bind/view failures free image+memory.
PyroWave open unwind: open_inner had ~20 fallible steps that each leaked
everything before them (instance, device, pyrowave objects, the whole
CSC pipeline). Rather than a parallel teardown guard — whose reviewed
hazards were a null-unsafe pyrowave_encoder_destroy and a drifting
duplicate of Drop — Self is now constructed right after create_device
with every later resource null, and the existing Drop (wait-idle first,
pw_enc null-guarded, delete-nullptr and VK_NULL_HANDLE destroys are
no-ops) is the single unwind path for error and normal teardown alike.
The ensure_cpu_rgb staging twins (create/allocate/bind, both backends)
and the RGB-direct make_view pair get the same discipline via a shared
make_host_buffer. Observed on hardware: 32 forced import failures, zero
fd drift (the new import_failure_leaks_no_fds smoke on RADV).
24-bpp CPU service (WP5.4): pixel_to_vk had no mapping for the packed
Rgb/Bgr the PipeWire portal negotiates, so a session committed to a path
the backend could not serve and died at its first frame. The filed
open-gate was rejected as a half-mirror — the dmabuf axis is keyed by
fourcc at submit, unknowable at open — so instead the CPU axis is
SERVED: a 3-to-4 expand at the staging upload (normalize_cpu_rgb, the
CPU twin of WP1.4's swscale expand), order-preserving for the CSC
samplers and BGRA-forced for the RGB-direct encode source, whose session
pictureFormat is B8G8R8A8 — the on-glass run caught R-first sources
violating VUID-vkCmdEncodeVideoKHR-pEncodeInfo-08207, a mismatch that
predates this change for plain Rgbx CPU sources. The dmabuf axis feeds
pf-zerocopy's raw-dmabuf degrade latch (3efbe416) from both Vulkan
import caches — deterministic refusals flip capture to CPU delivery,
which now serves every format capture produces; transient OOM and
native-NV12 sessions are excluded.
RECORDING-state hygiene: the fallible recording prefix (cursor prep,
import, staging) now resets the command buffer on error instead of
leaving it RECORDING for the next begin to trip
VUID-vkBeginCommandBuffer-commandBuffer-00049; PyroWave's submit-level
blanket reset is replaced by the same scoped shape, closing its
fence-timeout hole where a reset hit a PENDING buffer
(VUID-vkResetCommandBuffer-commandBuffer-00045).
Verified: docker linux/amd64 4-leg gate green; RADV 780M on-glass under
the validation layers at 1920x1080 — all 10 smokes pass (including the
new 24-bpp CSC + RGB-direct legs and both channel orders), decoded
colour truth checked with ffmpeg, and the only remaining validation
messages are the two documented VALVE-extension layer gaps plus a
pre-existing pyrowave shader/feature mismatch now filed in the planning
doc.
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -54,6 +54,59 @@ pub(crate) fn pixel_to_vk(fmt: PixelFormat) -> Option<vk::Format> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Normalize a CPU RGB payload for Vulkan upload. The packed 24-bpp `Rgb`/`Bgr` the PipeWire
|
||||
/// capturer can negotiate are expanded 3→4 into `scratch` (kept by the caller across frames — no
|
||||
/// per-frame allocation) with the pad byte = 0xFF; refusing them instead used to kill a session
|
||||
/// at its first frame (WP5.4). No packed 24-bpp VkFormat is reliably uploadable/sampleable on
|
||||
/// target GPUs, and this path is CPU-sourced by definition, so one cheap expand pass serves it
|
||||
/// (the same call NVENC answers with its swscale 3→4 expand, WP1.4).
|
||||
///
|
||||
/// `bgra_target = false` (the CSC paths): channel order is preserved — the sampler reads through
|
||||
/// the matching view format, so any 4-bpp order works and 4-bpp inputs pass through borrowed.
|
||||
/// `bgra_target = true` (the RGB-direct encode source): the output byte order is forced to
|
||||
/// B,G,R,X, because the video session's `pictureFormat` is `B8G8R8A8_UNORM` and
|
||||
/// VUID-vkCmdEncodeVideoKHR-pEncodeInfo-08207 requires the source image to match it — an
|
||||
/// R-first source (`Rgbx`/`Rgba`/`Rgb`) is channel-swapped during the same pass. (Caught live on
|
||||
/// RADV by `vulkan_smoke_rgb_cpu24`; the mismatch predates the 24-bpp support for `Rgbx` CPU
|
||||
/// sources.)
|
||||
///
|
||||
/// Payloads are tightly packed with no row padding (`FramePayload::Cpu`'s contract), so the
|
||||
/// conversion is row-agnostic; a truncated source yields a truncated output, which the upload
|
||||
/// paths already bound-check exactly as they did the raw bytes.
|
||||
pub(crate) fn normalize_cpu_rgb<'a>(
|
||||
fmt: PixelFormat,
|
||||
bytes: &'a [u8],
|
||||
scratch: &'a mut Vec<u8>,
|
||||
bgra_target: bool,
|
||||
) -> (PixelFormat, &'a [u8]) {
|
||||
// Per-pixel source layout: bytes-per-pixel + where R, G, B sit in each pixel.
|
||||
let (bpp, r, g, b) = match fmt {
|
||||
PixelFormat::Rgb => (3usize, 0usize, 1usize, 2usize),
|
||||
PixelFormat::Bgr => (3, 2, 1, 0),
|
||||
PixelFormat::Rgbx | PixelFormat::Rgba => (4, 0, 1, 2),
|
||||
PixelFormat::Bgrx | PixelFormat::Bgra => (4, 2, 1, 0),
|
||||
_ => return (fmt, bytes),
|
||||
};
|
||||
if bpp == 4 && (!bgra_target || b == 0) {
|
||||
return (fmt, bytes); // 4-bpp in an acceptable order: borrow untouched
|
||||
}
|
||||
let px = bytes.len() / bpp;
|
||||
scratch.clear();
|
||||
scratch.resize(px * 4, 0xFF);
|
||||
let (dr, dg, db) = if bgra_target { (2, 1, 0) } else { (r, g, b) };
|
||||
for (dst, src) in scratch.chunks_exact_mut(4).zip(bytes.chunks_exact(bpp)) {
|
||||
dst[dr] = src[r];
|
||||
dst[dg] = src[g];
|
||||
dst[db] = src[b];
|
||||
}
|
||||
let out_fmt = if bgra_target || b == 0 {
|
||||
PixelFormat::Bgrx
|
||||
} else {
|
||||
PixelFormat::Rgbx
|
||||
};
|
||||
(out_fmt, scratch.as_slice())
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn make_view(
|
||||
device: &ash::Device,
|
||||
image: vk::Image,
|
||||
@@ -70,6 +123,21 @@ pub(crate) unsafe fn make_view(
|
||||
)?)
|
||||
}
|
||||
|
||||
/// Whether a failed dmabuf import should count toward pf-zerocopy's raw-dmabuf degrade latch
|
||||
/// (`note_raw_dmabuf_import_failure` — 3 consecutive failures flip capture to CPU delivery for
|
||||
/// the process). Deterministic refusals (unsupported fourcc, the driver rejecting the buffer)
|
||||
/// must count — they repeat identically forever and the latch is their only recovery. Transient
|
||||
/// VRAM pressure must NOT: three tight allocation OOMs would otherwise permanently downgrade a
|
||||
/// working host to CPU capture.
|
||||
pub(crate) fn import_failure_feeds_latch(e: &anyhow::Error) -> bool {
|
||||
match e.downcast_ref::<vk::Result>() {
|
||||
Some(&r) => {
|
||||
r != vk::Result::ERROR_OUT_OF_DEVICE_MEMORY && r != vk::Result::ERROR_OUT_OF_HOST_MEMORY
|
||||
}
|
||||
None => true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a packed-RGB dmabuf as a SAMPLED VkImage (explicit DRM modifier). Caller destroys all
|
||||
/// three returned handles. Extracted verbatim from `vulkan_video.rs`'s import path.
|
||||
pub(crate) unsafe fn import_rgb_dmabuf(
|
||||
@@ -108,9 +176,15 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
profile_list: Option<&mut vk::VideoProfileListInfoKHR>,
|
||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||
use anyhow::Context;
|
||||
use std::os::fd::IntoRawFd;
|
||||
use std::os::fd::{AsRawFd, IntoRawFd};
|
||||
let fmt = fourcc_to_vk(d.fourcc)
|
||||
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
|
||||
// Dup the fd FIRST, and keep it OWNED: ownership transfers to Vulkan only on a SUCCESSFUL
|
||||
// `allocate_memory` (VK_KHR_external_memory_fd — from then on `vkFreeMemory` closes it), so
|
||||
// the release below sits in exactly that arm. Every earlier failure drops the `OwnedFd` for
|
||||
// a single clean close. An explicit `close` after a successful import would be a double
|
||||
// close — and a recycled fd number then clobbers an unrelated descriptor in this process.
|
||||
let dup = d.fd.try_clone().context("dup dmabuf fd")?;
|
||||
let planes: Vec<vk::SubresourceLayout> = if fmt == vk::Format::G8_B8R8_2PLANE_420_UNORM {
|
||||
let (uv_offset, uv_stride) = d.plane1.map(|(o, s)| (o as u64, s as u64)).unwrap_or((
|
||||
d.offset as u64 + d.stride as u64 * ch as u64,
|
||||
@@ -155,14 +229,16 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
ci = ci.push_next(pl);
|
||||
}
|
||||
let img = device.create_image(&ci, None)?;
|
||||
// dup the fd; Vulkan takes ownership of the dup on a successful import.
|
||||
let dup = d.fd.try_clone().context("dup dmabuf fd")?.into_raw_fd();
|
||||
// Unwind discipline below mirrors `make_plain_image`: every failure destroys what this call
|
||||
// created (and ONLY that — the caller's `DmabufFrame` fd stays theirs).
|
||||
let fd_props = {
|
||||
let mut p = vk::MemoryFdPropertiesKHR::default();
|
||||
// Borrow-only query (no ownership transfer); an error leaves memory_type_bits = 0 and
|
||||
// the fallback below uses the image requirements alone.
|
||||
let _ = (ext_fd.fp().get_memory_fd_properties_khr)(
|
||||
device.handle(),
|
||||
vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT,
|
||||
dup,
|
||||
dup.as_raw_fd(),
|
||||
&mut p,
|
||||
);
|
||||
p.memory_type_bits
|
||||
@@ -181,27 +257,87 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
let mut ded = vk::MemoryDedicatedAllocateInfo::default().image(img);
|
||||
let mut import = vk::ImportMemoryFdInfoKHR::default()
|
||||
.handle_type(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT)
|
||||
.fd(dup);
|
||||
let mem = device.allocate_memory(
|
||||
.fd(dup.as_raw_fd());
|
||||
let mem = match device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(ti)
|
||||
.push_next(&mut ded)
|
||||
.push_next(&mut import),
|
||||
None,
|
||||
)?;
|
||||
device.bind_image_memory(img, mem, 0)?;
|
||||
let view = device.create_image_view(
|
||||
) {
|
||||
Ok(mem) => {
|
||||
// Success transferred fd ownership to the memory object — release, don't close.
|
||||
let _ = dup.into_raw_fd();
|
||||
mem
|
||||
}
|
||||
Err(e) => {
|
||||
device.destroy_image(img, None);
|
||||
return Err(e.into()); // `dup` drops here: the one close of the failed import's fd
|
||||
}
|
||||
};
|
||||
if let Err(e) = device.bind_image_memory(img, mem, 0) {
|
||||
device.destroy_image(img, None);
|
||||
device.free_memory(mem, None); // closes the imported fd
|
||||
return Err(e.into());
|
||||
}
|
||||
let view = match device.create_image_view(
|
||||
&vk::ImageViewCreateInfo::default()
|
||||
.image(img)
|
||||
.view_type(vk::ImageViewType::TYPE_2D)
|
||||
.format(fmt)
|
||||
.subresource_range(color_range(0)),
|
||||
None,
|
||||
)?;
|
||||
) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
device.destroy_image(img, None);
|
||||
device.free_memory(mem, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
Ok((img, mem, view))
|
||||
}
|
||||
|
||||
/// Create + allocate + bind a host-visible/coherent buffer with `make_plain_image`'s unwind
|
||||
/// discipline: on any failure everything this call created is destroyed before returning, so
|
||||
/// callers can `?` freely. Both `ensure_cpu_rgb` staging twins open-coded this sequence and
|
||||
/// leaked the buffer (and then buffer+memory) on the allocate/bind failure arms.
|
||||
pub(crate) unsafe fn make_host_buffer(
|
||||
device: &ash::Device,
|
||||
mp: &vk::PhysicalDeviceMemoryProperties,
|
||||
size: u64,
|
||||
usage: vk::BufferUsageFlags,
|
||||
) -> Result<(vk::Buffer, vk::DeviceMemory)> {
|
||||
let buf = device.create_buffer(
|
||||
&vk::BufferCreateInfo::default().size(size).usage(usage),
|
||||
None,
|
||||
)?;
|
||||
let req = device.get_buffer_memory_requirements(buf);
|
||||
let mem = match device.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(find_mem(
|
||||
mp,
|
||||
req.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE | vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
)),
|
||||
None,
|
||||
) {
|
||||
Ok(m) => m,
|
||||
Err(e) => {
|
||||
device.destroy_buffer(buf, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
};
|
||||
if let Err(e) = device.bind_buffer_memory(buf, mem, 0) {
|
||||
device.destroy_buffer(buf, None);
|
||||
device.free_memory(mem, None);
|
||||
return Err(e.into());
|
||||
}
|
||||
Ok((buf, mem))
|
||||
}
|
||||
|
||||
pub(crate) unsafe fn make_plain_image(
|
||||
device: &ash::Device,
|
||||
mp: &vk::PhysicalDeviceMemoryProperties,
|
||||
@@ -259,3 +395,80 @@ pub(crate) unsafe fn make_plain_image(
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// CSC mode (`bgra_target = false`): the 3→4 expand is a pure byte shuffle — no channel
|
||||
/// reorder, pad byte 0xFF, truncated tail pixels dropped (never overrun) — and 4-bpp inputs
|
||||
/// pass through borrowed untouched.
|
||||
#[test]
|
||||
fn normalize_cpu_rgb_expands_24bpp_and_borrows_4bpp() {
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Rgb, &[1, 2, 3, 4, 5, 6], &mut scratch, false);
|
||||
assert_eq!(f, PixelFormat::Rgbx);
|
||||
assert_eq!(b, &[1, 2, 3, 0xFF, 4, 5, 6, 0xFF]);
|
||||
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgr, &[9, 8, 7], &mut scratch, false);
|
||||
assert_eq!(f, PixelFormat::Bgrx);
|
||||
assert_eq!(b, &[9, 8, 7, 0xFF]);
|
||||
|
||||
// Truncated tail: 5 bytes = one whole pixel + a 2-byte remainder that must be dropped.
|
||||
let mut scratch = Vec::new();
|
||||
let (_, b) = normalize_cpu_rgb(PixelFormat::Rgb, &[1, 2, 3, 4, 5], &mut scratch, false);
|
||||
assert_eq!(b, &[1, 2, 3, 0xFF]);
|
||||
|
||||
// 4-bpp passthrough: borrowed, scratch untouched.
|
||||
let src = [10u8, 20, 30, 40];
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgrx, &src, &mut scratch, false);
|
||||
assert_eq!(f, PixelFormat::Bgrx);
|
||||
assert!(std::ptr::eq(b.as_ptr(), src.as_ptr()));
|
||||
assert!(scratch.is_empty());
|
||||
|
||||
// The 4-bpp mapping the expand lands on matches pixel_to_vk's existing table.
|
||||
assert_eq!(
|
||||
pixel_to_vk(PixelFormat::Rgbx),
|
||||
Some(vk::Format::R8G8B8A8_UNORM)
|
||||
);
|
||||
assert_eq!(
|
||||
pixel_to_vk(PixelFormat::Bgrx),
|
||||
Some(vk::Format::B8G8R8A8_UNORM)
|
||||
);
|
||||
}
|
||||
|
||||
/// RGB-direct mode (`bgra_target = true`): everything lands in B,G,R,X order because the
|
||||
/// video session's `pictureFormat` is `B8G8R8A8_UNORM` and the encode source must match it
|
||||
/// (VUID-vkCmdEncodeVideoKHR-pEncodeInfo-08207 — caught live on RADV). B-first inputs pass
|
||||
/// through borrowed; R-first inputs are channel-swapped, 3-bpp and 4-bpp alike.
|
||||
#[test]
|
||||
fn normalize_cpu_rgb_forces_bgra_for_the_encode_source() {
|
||||
// Rgb (R,G,B) → B,G,R,X with the swap folded into the expand.
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Rgb, &[1, 2, 3], &mut scratch, true);
|
||||
assert_eq!(f, PixelFormat::Bgrx);
|
||||
assert_eq!(b, &[3, 2, 1, 0xFF]);
|
||||
|
||||
// Bgr (B,G,R) → same order, expanded.
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgr, &[9, 8, 7], &mut scratch, true);
|
||||
assert_eq!(f, PixelFormat::Bgrx);
|
||||
assert_eq!(b, &[9, 8, 7, 0xFF]);
|
||||
|
||||
// Rgbx: 4-bpp but R-first — swapped, alpha replaced by the 0xFF pad.
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Rgbx, &[1, 2, 3, 4], &mut scratch, true);
|
||||
assert_eq!(f, PixelFormat::Bgrx);
|
||||
assert_eq!(b, &[3, 2, 1, 0xFF]);
|
||||
|
||||
// Bgrx/Bgra already match the session order: borrowed untouched.
|
||||
let src = [10u8, 20, 30, 40];
|
||||
let mut scratch = Vec::new();
|
||||
let (f, b) = normalize_cpu_rgb(PixelFormat::Bgra, &src, &mut scratch, true);
|
||||
assert_eq!(f, PixelFormat::Bgra);
|
||||
assert!(std::ptr::eq(b.as_ptr(), src.as_ptr()));
|
||||
assert!(scratch.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -10,7 +10,10 @@
|
||||
//! The AV1 encode structs our pinned `ash 0.38` predates are vendored in `vk_av1_encode.rs`.
|
||||
#![allow(clippy::too_many_arguments)]
|
||||
|
||||
use super::vk_util::{color_range, find_mem, make_plain_image, make_view, pixel_to_vk};
|
||||
use super::vk_util::{
|
||||
color_range, find_mem, import_failure_feeds_latch, make_host_buffer, make_plain_image,
|
||||
make_view, normalize_cpu_rgb, pixel_to_vk,
|
||||
};
|
||||
use crate::{Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use ash::vk;
|
||||
@@ -560,6 +563,8 @@ pub struct VulkanVideoEncoder {
|
||||
/// separately from the host's fence wait; 0.0 means disabled or unsupported.
|
||||
ts_period_ns: f64,
|
||||
perf_at: std::time::Instant,
|
||||
/// Reused 3→4 expansion buffer for 24-bpp CPU payloads (`vk_util::normalize_cpu_rgb`).
|
||||
cpu_expand: Vec<u8>,
|
||||
/// RGB-direct (EFC) session config — `Some` ⇒ the session's picture format is BGRA, frames
|
||||
/// are handed to the encoder as RGB (dmabuf import or CPU upload) and the VCN front-end does
|
||||
/// the CSC; `None` ⇒ the compute-CSC path. Fixed per session (the picture format is baked
|
||||
@@ -1306,6 +1311,7 @@ impl VulkanVideoEncoder {
|
||||
quality_level,
|
||||
ts_period_ns,
|
||||
perf_at: std::time::Instant::now(),
|
||||
cpu_expand: Vec::new(),
|
||||
rgb: rgb_cfg,
|
||||
native_nv12,
|
||||
warned_cursor: false,
|
||||
@@ -1545,7 +1551,27 @@ impl VulkanVideoEncoder {
|
||||
if let Some(&(_, _, img, _, view)) = self.import_cache.iter().find(|e| (e.0, e.1) == key) {
|
||||
return Ok((img, view, false));
|
||||
}
|
||||
let (img, mem, view) = self.import_dmabuf(d, cw, ch)?;
|
||||
// Feed pf-zerocopy's raw-dmabuf degrade latch (wired for the libav path in 3efbe416):
|
||||
// a deterministic import refusal repeats identically forever, and the latch — capture
|
||||
// negotiates CPU delivery from the next session — is its only recovery; the CPU path
|
||||
// serves every format capture produces (`normalize_cpu_rgb`). Excluded: transient
|
||||
// allocation OOM (`import_failure_feeds_latch`), and native-NV12 sessions entirely — an
|
||||
// NV12-layout quirk should cost the NV12 preference, not ALL dmabuf capture, and that
|
||||
// narrower response doesn't exist yet (noted in the phase-5 handoff as follow-up).
|
||||
let (img, mem, view) = match self.import_dmabuf(d, cw, ch) {
|
||||
Ok(t) => {
|
||||
if !self.native_nv12 {
|
||||
pf_zerocopy::note_raw_dmabuf_import_ok();
|
||||
}
|
||||
t
|
||||
}
|
||||
Err(e) => {
|
||||
if !self.native_nv12 && import_failure_feeds_latch(&e) {
|
||||
pf_zerocopy::note_raw_dmabuf_import_failure(&format!("{e:#}"));
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
// Bound the cache; evict oldest (FIFO). A stable PipeWire pool never trips this in steady state
|
||||
// (all imports resident); it only cycles across a pool change (which also rebuilds the session).
|
||||
// Up to `ring_depth - 1` submitted frames may still be executing against a cached image
|
||||
@@ -1631,7 +1657,16 @@ impl VulkanVideoEncoder {
|
||||
&mut plist,
|
||||
&fams,
|
||||
)?;
|
||||
let v = make_view(&dev, i, fmt, 0)?;
|
||||
// `make_video_image` unwinds internally, but a view failure here leaked the
|
||||
// image+memory pair it had just handed over.
|
||||
let v = match make_view(&dev, i, fmt, 0) {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
dev.destroy_image(i, None);
|
||||
dev.free_memory(m, None);
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
(i, m, v)
|
||||
} else {
|
||||
make_plain_image(
|
||||
@@ -1654,25 +1689,12 @@ impl VulkanVideoEncoder {
|
||||
dev.destroy_buffer(b, None);
|
||||
dev.free_memory(m, None);
|
||||
}
|
||||
let buf = dev.create_buffer(
|
||||
&vk::BufferCreateInfo::default()
|
||||
.size(need)
|
||||
.usage(vk::BufferUsageFlags::TRANSFER_SRC),
|
||||
None,
|
||||
let (buf, mem) = make_host_buffer(
|
||||
&dev,
|
||||
&self.mem_props,
|
||||
need,
|
||||
vk::BufferUsageFlags::TRANSFER_SRC,
|
||||
)?;
|
||||
let req = dev.get_buffer_memory_requirements(buf);
|
||||
let mem = dev.allocate_memory(
|
||||
&vk::MemoryAllocateInfo::default()
|
||||
.allocation_size(req.size)
|
||||
.memory_type_index(find_mem(
|
||||
&self.mem_props,
|
||||
req.memory_type_bits,
|
||||
vk::MemoryPropertyFlags::HOST_VISIBLE
|
||||
| vk::MemoryPropertyFlags::HOST_COHERENT,
|
||||
)),
|
||||
None,
|
||||
)?;
|
||||
dev.bind_buffer_memory(buf, mem, 0)?;
|
||||
self.frames[slot].cpu_stage = Some((buf, mem, need));
|
||||
}
|
||||
let (_, m, _) = self.frames[slot].cpu_stage.unwrap();
|
||||
@@ -1832,121 +1854,153 @@ impl VulkanVideoEncoder {
|
||||
&vk::CommandBufferBeginInfo::default()
|
||||
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
||||
)?;
|
||||
// PUNKTFUNK_PERF: bracket the whole compute batch (import barriers + cursor prep + CSC
|
||||
// dispatch + plane copies) with timestamps — read back in `read_slot` as `csc_us`, the
|
||||
// half of the fence wait that is NOT the ASIC encode.
|
||||
self.frames[slot].ts_written = false;
|
||||
if self.ts_period_ns > 0.0 {
|
||||
dev.cmd_reset_query_pool(compute_cmd, ts_pool, 0, 2);
|
||||
dev.cmd_write_timestamp2(compute_cmd, vk::PipelineStageFlags2::NONE, ts_pool, 0);
|
||||
// This batch resets the pool and closes it below, so `read_slot` may read it.
|
||||
self.frames[slot].ts_written = true;
|
||||
}
|
||||
|
||||
// Cursor-as-metadata: refresh this slot's cursor image (only when the bitmap changed) and
|
||||
// get the shader push constant. Recorded into `compute_cmd` before the CSC dispatch samples it.
|
||||
let cursor_pc = self.prep_cursor(slot, compute_cmd, frame.cursor.as_ref())?;
|
||||
|
||||
let rgb_view = match &frame.payload {
|
||||
FramePayload::Dmabuf(d) => {
|
||||
// Reuse the per-buffer import (PipeWire cycles a small pool) — no per-frame VkImage
|
||||
// create/import/destroy. The producer wrote new content out-of-band, so still acquire
|
||||
// from FOREIGN each frame; a fresh import starts UNDEFINED (preserves modifier-tiled
|
||||
// data), a cached one is already SHADER_READ_ONLY_OPTIMAL.
|
||||
let (img, view, fresh) = self.import_cached(d, frame.width, frame.height)?;
|
||||
// First import: acquire from the foreign producer (UNDEFINED preserves the modifier-tiled
|
||||
// bytes). Cached re-read: we still own it, so no queue-family transfer — just a visibility
|
||||
// barrier so the shader read sees the content the producer wrote out-of-band this frame
|
||||
// (single-GPU coherent; the capture layer guarantees the buffer is ready at hand-off).
|
||||
let (old, src_qf, dst_qf) = if fresh {
|
||||
(
|
||||
vk::ImageLayout::UNDEFINED,
|
||||
vk::QUEUE_FAMILY_FOREIGN_EXT,
|
||||
self.compute_family,
|
||||
)
|
||||
} else {
|
||||
(
|
||||
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
|
||||
vk::QUEUE_FAMILY_IGNORED,
|
||||
vk::QUEUE_FAMILY_IGNORED,
|
||||
)
|
||||
};
|
||||
let acq = vk::ImageMemoryBarrier2::default()
|
||||
.src_stage_mask(vk::PipelineStageFlags2::NONE)
|
||||
.src_access_mask(vk::AccessFlags2::NONE)
|
||||
.dst_stage_mask(vk::PipelineStageFlags2::COMPUTE_SHADER)
|
||||
.dst_access_mask(vk::AccessFlags2::SHADER_READ)
|
||||
.old_layout(old)
|
||||
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
.src_queue_family_index(src_qf)
|
||||
.dst_queue_family_index(dst_qf)
|
||||
.image(img)
|
||||
.subresource_range(color_range(0));
|
||||
dev.cmd_pipeline_barrier2(
|
||||
compute_cmd,
|
||||
&vk::DependencyInfo::default().image_memory_barriers(&[acq]),
|
||||
);
|
||||
view
|
||||
// The fallible recording prefix (cursor prep, dmabuf import, CPU staging) runs in one
|
||||
// closure whose error arm resets `compute_cmd`: an early return used to leave the buffer
|
||||
// RECORDING, and the next frame's `begin` on a RECORDING buffer violates
|
||||
// VUID-vkBeginCommandBuffer-commandBuffer-00049 — the pool's implicit reset covers only
|
||||
// INITIAL/EXECUTABLE/INVALID. Never PENDING here: nothing is submitted until stage 4, so
|
||||
// the error-arm reset is legal on every path.
|
||||
let prefix = (|| -> Result<([i32; 4], vk::ImageView)> {
|
||||
// PUNKTFUNK_PERF: bracket the whole compute batch (import barriers + cursor prep + CSC
|
||||
// dispatch + plane copies) with timestamps — read back in `read_slot` as `csc_us`, the
|
||||
// half of the fence wait that is NOT the ASIC encode.
|
||||
self.frames[slot].ts_written = false;
|
||||
if self.ts_period_ns > 0.0 {
|
||||
dev.cmd_reset_query_pool(compute_cmd, ts_pool, 0, 2);
|
||||
dev.cmd_write_timestamp2(compute_cmd, vk::PipelineStageFlags2::NONE, ts_pool, 0);
|
||||
// This batch resets the pool and closes it below, so `read_slot` may read it.
|
||||
self.frames[slot].ts_written = true;
|
||||
}
|
||||
FramePayload::Cpu(bytes) => {
|
||||
let fmt = pixel_to_vk(frame.format).context("unsupported CPU pixel format")?;
|
||||
let view = self.ensure_cpu_rgb(slot, fmt, bytes, frame.width, frame.height)?;
|
||||
let (img, ..) = self.frames[slot].cpu_img.unwrap();
|
||||
let (stage, ..) = self.frames[slot].cpu_stage.unwrap();
|
||||
let to_dst = vk::ImageMemoryBarrier2::default()
|
||||
.src_stage_mask(vk::PipelineStageFlags2::NONE)
|
||||
.src_access_mask(vk::AccessFlags2::NONE)
|
||||
.dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER)
|
||||
.dst_access_mask(vk::AccessFlags2::TRANSFER_WRITE)
|
||||
.old_layout(vk::ImageLayout::UNDEFINED)
|
||||
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.image(img)
|
||||
.subresource_range(color_range(0));
|
||||
dev.cmd_pipeline_barrier2(
|
||||
compute_cmd,
|
||||
&vk::DependencyInfo::default().image_memory_barriers(&[to_dst]),
|
||||
);
|
||||
dev.cmd_copy_buffer_to_image(
|
||||
compute_cmd,
|
||||
stage,
|
||||
img,
|
||||
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
|
||||
&[vk::BufferImageCopy::default()
|
||||
.image_subresource(
|
||||
vk::ImageSubresourceLayers::default()
|
||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
||||
.layer_count(1),
|
||||
|
||||
// Cursor-as-metadata: refresh this slot's cursor image (only when the bitmap changed)
|
||||
// and get the shader push constant. Recorded into `compute_cmd` before the CSC
|
||||
// dispatch samples it.
|
||||
let cursor_pc = self.prep_cursor(slot, compute_cmd, frame.cursor.as_ref())?;
|
||||
|
||||
let rgb_view = match &frame.payload {
|
||||
FramePayload::Dmabuf(d) => {
|
||||
// Reuse the per-buffer import (PipeWire cycles a small pool) — no per-frame VkImage
|
||||
// create/import/destroy. The producer wrote new content out-of-band, so still acquire
|
||||
// from FOREIGN each frame; a fresh import starts UNDEFINED (preserves modifier-tiled
|
||||
// data), a cached one is already SHADER_READ_ONLY_OPTIMAL.
|
||||
let (img, view, fresh) = self.import_cached(d, frame.width, frame.height)?;
|
||||
// First import: acquire from the foreign producer (UNDEFINED preserves the modifier-tiled
|
||||
// bytes). Cached re-read: we still own it, so no queue-family transfer — just a visibility
|
||||
// barrier so the shader read sees the content the producer wrote out-of-band this frame
|
||||
// (single-GPU coherent; the capture layer guarantees the buffer is ready at hand-off).
|
||||
let (old, src_qf, dst_qf) = if fresh {
|
||||
(
|
||||
vk::ImageLayout::UNDEFINED,
|
||||
vk::QUEUE_FAMILY_FOREIGN_EXT,
|
||||
self.compute_family,
|
||||
)
|
||||
// The staging buffer holds the REAL frame tightly packed and the image
|
||||
// is source-sized (see ensure_cpu_rgb) — an aligned-size extent here
|
||||
// sheared rows against the packed buffer and left garbage rows at
|
||||
// unaligned modes.
|
||||
.image_extent(vk::Extent3D {
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
depth: 1,
|
||||
})],
|
||||
);
|
||||
let to_read = vk::ImageMemoryBarrier2::default()
|
||||
.src_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER)
|
||||
.src_access_mask(vk::AccessFlags2::TRANSFER_WRITE)
|
||||
.dst_stage_mask(vk::PipelineStageFlags2::COMPUTE_SHADER)
|
||||
.dst_access_mask(vk::AccessFlags2::SHADER_READ)
|
||||
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.image(img)
|
||||
.subresource_range(color_range(0));
|
||||
dev.cmd_pipeline_barrier2(
|
||||
compute_cmd,
|
||||
&vk::DependencyInfo::default().image_memory_barriers(&[to_read]),
|
||||
);
|
||||
view
|
||||
} else {
|
||||
(
|
||||
vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL,
|
||||
vk::QUEUE_FAMILY_IGNORED,
|
||||
vk::QUEUE_FAMILY_IGNORED,
|
||||
)
|
||||
};
|
||||
let acq = vk::ImageMemoryBarrier2::default()
|
||||
.src_stage_mask(vk::PipelineStageFlags2::NONE)
|
||||
.src_access_mask(vk::AccessFlags2::NONE)
|
||||
.dst_stage_mask(vk::PipelineStageFlags2::COMPUTE_SHADER)
|
||||
.dst_access_mask(vk::AccessFlags2::SHADER_READ)
|
||||
.old_layout(old)
|
||||
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
.src_queue_family_index(src_qf)
|
||||
.dst_queue_family_index(dst_qf)
|
||||
.image(img)
|
||||
.subresource_range(color_range(0));
|
||||
dev.cmd_pipeline_barrier2(
|
||||
compute_cmd,
|
||||
&vk::DependencyInfo::default().image_memory_barriers(&[acq]),
|
||||
);
|
||||
view
|
||||
}
|
||||
FramePayload::Cpu(bytes) => {
|
||||
// 24-bpp Rgb/Bgr expands 3→4 first (see `normalize_cpu_rgb`) — refusing it here
|
||||
// used to kill the session at its first frame, and the RGB-direct padding branch
|
||||
// in `ensure_cpu_rgb` does 4-bpp index math on the raw bytes, so the expansion
|
||||
// must happen BEFORE any of it sees the payload.
|
||||
let mut scratch = std::mem::take(&mut self.cpu_expand);
|
||||
let (norm_fmt, norm_bytes) =
|
||||
normalize_cpu_rgb(frame.format, bytes, &mut scratch, false);
|
||||
let fmt = pixel_to_vk(norm_fmt).context("unsupported CPU pixel format");
|
||||
let view = match fmt {
|
||||
Ok(f) => {
|
||||
self.ensure_cpu_rgb(slot, f, norm_bytes, frame.width, frame.height)
|
||||
}
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
self.cpu_expand = scratch;
|
||||
let view = view?;
|
||||
let (img, ..) = self.frames[slot].cpu_img.unwrap();
|
||||
let (stage, ..) = self.frames[slot].cpu_stage.unwrap();
|
||||
let to_dst = vk::ImageMemoryBarrier2::default()
|
||||
.src_stage_mask(vk::PipelineStageFlags2::NONE)
|
||||
.src_access_mask(vk::AccessFlags2::NONE)
|
||||
.dst_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER)
|
||||
.dst_access_mask(vk::AccessFlags2::TRANSFER_WRITE)
|
||||
.old_layout(vk::ImageLayout::UNDEFINED)
|
||||
.new_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.image(img)
|
||||
.subresource_range(color_range(0));
|
||||
dev.cmd_pipeline_barrier2(
|
||||
compute_cmd,
|
||||
&vk::DependencyInfo::default().image_memory_barriers(&[to_dst]),
|
||||
);
|
||||
dev.cmd_copy_buffer_to_image(
|
||||
compute_cmd,
|
||||
stage,
|
||||
img,
|
||||
vk::ImageLayout::TRANSFER_DST_OPTIMAL,
|
||||
&[vk::BufferImageCopy::default()
|
||||
.image_subresource(
|
||||
vk::ImageSubresourceLayers::default()
|
||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
||||
.layer_count(1),
|
||||
)
|
||||
// The staging buffer holds the REAL frame tightly packed and the image
|
||||
// is source-sized (see ensure_cpu_rgb) — an aligned-size extent here
|
||||
// sheared rows against the packed buffer and left garbage rows at
|
||||
// unaligned modes.
|
||||
.image_extent(vk::Extent3D {
|
||||
width: frame.width,
|
||||
height: frame.height,
|
||||
depth: 1,
|
||||
})],
|
||||
);
|
||||
let to_read = vk::ImageMemoryBarrier2::default()
|
||||
.src_stage_mask(vk::PipelineStageFlags2::ALL_TRANSFER)
|
||||
.src_access_mask(vk::AccessFlags2::TRANSFER_WRITE)
|
||||
.dst_stage_mask(vk::PipelineStageFlags2::COMPUTE_SHADER)
|
||||
.dst_access_mask(vk::AccessFlags2::SHADER_READ)
|
||||
.old_layout(vk::ImageLayout::TRANSFER_DST_OPTIMAL)
|
||||
.new_layout(vk::ImageLayout::SHADER_READ_ONLY_OPTIMAL)
|
||||
.src_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.dst_queue_family_index(vk::QUEUE_FAMILY_IGNORED)
|
||||
.image(img)
|
||||
.subresource_range(color_range(0));
|
||||
dev.cmd_pipeline_barrier2(
|
||||
compute_cmd,
|
||||
&vk::DependencyInfo::default().image_memory_barriers(&[to_read]),
|
||||
);
|
||||
view
|
||||
}
|
||||
_ => bail!("vulkan-encode: unsupported FramePayload (need Dmabuf or Cpu RGB)"),
|
||||
};
|
||||
Ok((cursor_pc, rgb_view))
|
||||
})();
|
||||
let (cursor_pc, rgb_view) = match prefix {
|
||||
Ok(v) => v,
|
||||
Err(e) => {
|
||||
// SAFETY-ADJACENT: RECORDING (never submitted in this fn yet), pool allows reset.
|
||||
let _ = dev.reset_command_buffer(compute_cmd, vk::CommandBufferResetFlags::empty());
|
||||
return Err(e);
|
||||
}
|
||||
_ => bail!("vulkan-encode: unsupported FramePayload (need Dmabuf or Cpu RGB)"),
|
||||
};
|
||||
self.bind_rgb(csc_set, rgb_view);
|
||||
|
||||
@@ -2500,8 +2554,23 @@ impl VulkanVideoEncoder {
|
||||
(pad_img, pad_view, SrcAcquire::CscGeneral, true)
|
||||
}
|
||||
FramePayload::Cpu(bytes) => {
|
||||
let fmt = pixel_to_vk(frame.format).context("unsupported CPU pixel format")?;
|
||||
let view = self.ensure_cpu_rgb(slot, fmt, bytes, frame.width, frame.height)?;
|
||||
// 24-bpp Rgb/Bgr expands 3→4 BEFORE the staging/padding math (which is all
|
||||
// 4-bpp) — see `normalize_cpu_rgb`. BGRA-forced: this image is the ENCODE
|
||||
// source, and the session's `pictureFormat` is B8G8R8A8, so an R-first source
|
||||
// violates VUID-vkCmdEncodeVideoKHR-pEncodeInfo-08207 (caught on RADV by
|
||||
// `vulkan_smoke_rgb_cpu24`; true for plain `Rgbx` CPU sources before the 24-bpp
|
||||
// work too). This arm's `begin_command_buffer` comes AFTER the fallible steps,
|
||||
// so no reset-on-error wrap is needed here.
|
||||
let mut scratch = std::mem::take(&mut self.cpu_expand);
|
||||
let (norm_fmt, norm_bytes) =
|
||||
normalize_cpu_rgb(frame.format, bytes, &mut scratch, true);
|
||||
let fmt = pixel_to_vk(norm_fmt).context("unsupported CPU pixel format");
|
||||
let view = match fmt {
|
||||
Ok(f) => self.ensure_cpu_rgb(slot, f, norm_bytes, frame.width, frame.height),
|
||||
Err(e) => Err(e),
|
||||
};
|
||||
self.cpu_expand = scratch;
|
||||
let view = view?;
|
||||
let (img, ..) = self.frames[slot].cpu_img.expect("ensure_cpu_rgb built it");
|
||||
let (stage, ..) = self.frames[slot]
|
||||
.cpu_stage
|
||||
@@ -4858,6 +4927,99 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// 24-bpp packed CPU frame (`PixelFormat::Rgb`/`Bgr` — the PipeWire portal's CPU
|
||||
/// negotiation). `rgb` is (r, g, b) regardless of `fmt`'s byte order.
|
||||
fn cpu_frame_24(w: u32, h: u32, pts_ns: u64, rgb: [u8; 3], fmt: PixelFormat) -> CapturedFrame {
|
||||
let px = match fmt {
|
||||
PixelFormat::Rgb => [rgb[0], rgb[1], rgb[2]],
|
||||
PixelFormat::Bgr => [rgb[2], rgb[1], rgb[0]],
|
||||
_ => unreachable!("24-bpp helper"),
|
||||
};
|
||||
let mut buf = vec![0u8; (w * h * 3) as usize];
|
||||
for p in buf.chunks_exact_mut(3) {
|
||||
p.copy_from_slice(&px);
|
||||
}
|
||||
CapturedFrame {
|
||||
width: w,
|
||||
height: h,
|
||||
pts_ns,
|
||||
format: fmt,
|
||||
payload: FramePayload::Cpu(buf),
|
||||
cursor: None,
|
||||
}
|
||||
}
|
||||
|
||||
/// Drive one whole 24-bpp CPU session (WP5.4: served via `normalize_cpu_rgb`, not refused).
|
||||
/// Frames alternate Rgb/Bgr, which also crosses the staging image's format re-key each
|
||||
/// frame. Returns the AUs; colour truth is checked out-of-band (the dump + ffmpeg bars
|
||||
/// recipe from WP1.4) — in-tree the load-bearing assertions are that every submit encodes
|
||||
/// and, on-glass, that the validation layers stay silent through the expand + upload.
|
||||
fn run_smoke_cpu24(rgb_direct: bool) -> Option<Vec<crate::EncodedFrame>> {
|
||||
let env_dim = |k: &str, d: u32| {
|
||||
std::env::var(k)
|
||||
.ok()
|
||||
.and_then(|v| v.parse().ok())
|
||||
.unwrap_or(d)
|
||||
};
|
||||
let (w, h) = (env_dim("PF_SMOKE_W", 256), env_dim("PF_SMOKE_H", 256));
|
||||
let mut enc = VulkanVideoEncoder::open_opts(Codec::H265, w, h, 60, 10_000_000, rgb_direct)
|
||||
.expect("open");
|
||||
if rgb_direct && enc.rgb.is_none() {
|
||||
eprintln!("run_smoke_cpu24: RGB-direct unavailable on this driver — skipping");
|
||||
return None;
|
||||
}
|
||||
let colors: [[u8; 3]; 4] = [[200, 40, 40], [40, 200, 40], [40, 40, 200], [200, 200, 40]];
|
||||
let mut aus: Vec<crate::EncodedFrame> = Vec::new();
|
||||
for i in 0..8usize {
|
||||
let fmt = if i % 2 == 0 {
|
||||
PixelFormat::Rgb
|
||||
} else {
|
||||
PixelFormat::Bgr
|
||||
};
|
||||
let frame = cpu_frame_24(w, h, i as u64 * 16_666_667, colors[i / 2], fmt);
|
||||
enc.submit_indexed(&frame, i as u32).expect("submit 24-bpp");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus.push(au);
|
||||
}
|
||||
}
|
||||
enc.flush().expect("flush");
|
||||
while let Some(au) = enc.poll().expect("poll") {
|
||||
aus.push(au);
|
||||
}
|
||||
assert_eq!(aus.len(), 8, "one AU per 24-bpp frame");
|
||||
assert!(aus[0].keyframe, "frame 0 must be IDR");
|
||||
Some(aus)
|
||||
}
|
||||
|
||||
/// WP5.4 smoke — 24-bpp CPU frames through the CSC path.
|
||||
#[test]
|
||||
#[ignore = "needs a real VK_KHR_video_encode_h265 device (run on the RADV host, not the build box)"]
|
||||
fn vulkan_smoke_cpu_rgb24() {
|
||||
let aus = run_smoke_cpu24(false).expect("CSC mode never soft-skips");
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let full: Vec<u8> = aus.iter().flat_map(|a| a.data.iter().copied()).collect();
|
||||
let p = format!("{home}/vkenc-host-smoke-cpu24.h265");
|
||||
let _ = std::fs::write(&p, &full);
|
||||
eprintln!("vulkan_smoke_cpu_rgb24: wrote {p} ({} bytes)", full.len());
|
||||
}
|
||||
}
|
||||
|
||||
/// WP5.4 smoke, RGB-direct twin — the expanded R8G8B8A8/B8G8R8A8 image IS the encode source
|
||||
/// here, so this also answers whether the EFC profile accepts an RGBA-order source on this
|
||||
/// hardware (critique F7: unverified until run). Soft-skips without the VALVE extension.
|
||||
#[test]
|
||||
#[ignore = "needs VK_VALVE_video_encode_rgb_conversion (RADV >= Mesa 26.0 on EFC hardware)"]
|
||||
fn vulkan_smoke_rgb_cpu24() {
|
||||
if let Some(aus) = run_smoke_cpu24(true) {
|
||||
if let Ok(home) = std::env::var("HOME") {
|
||||
let full: Vec<u8> = aus.iter().flat_map(|a| a.data.iter().copied()).collect();
|
||||
let p = format!("{home}/vkenc-host-smoke-rgb-cpu24.h265");
|
||||
let _ = std::fs::write(&p, &full);
|
||||
eprintln!("vulkan_smoke_rgb_cpu24: wrote {p} ({} bytes)", full.len());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A CPU-capture source that CHANGES SIZE mid-session must not copy past the cached staging
|
||||
/// image. `ensure_cpu_rgb` keys that image on (format, width, height); when it was keyed on
|
||||
/// format alone the image kept the FIRST frame's size while `cmd_copy_buffer_to_image` used the
|
||||
|
||||
Reference in New Issue
Block a user