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

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:
2026-07-25 16:51:27 +02:00
co-authored by Claude Fable 5
parent e0e845b7df
commit 47a23bec12
3 changed files with 1148 additions and 608 deletions
+297 -135
View File
@@ -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