Compare commits
13 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 42198eb1b6 | |||
| b4fbc94e36 | |||
| 004f0cacb8 | |||
| 49c6dd00c9 | |||
| e7c6c96e5f | |||
| da134ad045 | |||
| 428bd2519c | |||
| 87d7eceabf | |||
| 1d4795666e | |||
| 58b27acbd5 | |||
| 333c8979e9 | |||
| 46d09ed973 | |||
| 946f1b3d69 |
Generated
+1
@@ -2881,6 +2881,7 @@ dependencies = [
|
||||
"libvpl-sys",
|
||||
"nvidia-video-codec-sdk",
|
||||
"openh264",
|
||||
"pf-capture",
|
||||
"pf-frame",
|
||||
"pf-gpu",
|
||||
"pf-host-config",
|
||||
|
||||
@@ -24,6 +24,16 @@ use pf_frame::DmabufFrame;
|
||||
pub trait Capturer: Send {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame>;
|
||||
|
||||
/// [`next_frame`](Self::next_frame) with a caller-chosen first-frame budget instead of the
|
||||
/// backend's default. The pipeline retry loop shortens its FIRST attempt's wait: a PipeWire
|
||||
/// stream connected while gamescope re-inits its headless takeover can negotiate a format,
|
||||
/// reach `Streaming`, and still never receive a buffer — a fresh connect then delivers within
|
||||
/// ~0.5 s, so waiting out the full default budget on a doomed stream just delays the retry
|
||||
/// that fixes it. Backends without an internal wait budget ignore it (the default delegates).
|
||||
fn next_frame_within(&mut self, _budget: std::time::Duration) -> Result<CapturedFrame> {
|
||||
self.next_frame()
|
||||
}
|
||||
|
||||
/// Non-blocking: the freshest frame available since the last call, or `None` if none has
|
||||
/// arrived (the caller reuses its last frame to hold a steady output rate). The default
|
||||
/// just produces a frame each call — fine for instant synthetic sources; the portal
|
||||
@@ -249,6 +259,12 @@ pub struct ZeroCopyPolicy {
|
||||
/// passthrough (like the VAAPI backend) instead of the EGL→CUDA import whose payloads only
|
||||
/// NVENC can consume. Per-session (the codec is negotiated), unlike `backend_is_vaapi`.
|
||||
pub pyrowave_session: bool,
|
||||
/// THIS session's encoder can ingest a producer-native NV12 capture (the Linux raw Vulkan
|
||||
/// Video backend on an H265/AV1 session — resolved by the host facade via
|
||||
/// `pf_encode::linux_native_nv12_ok`). Gates whether the negotiation PREFERS gamescope's
|
||||
/// producer-side NV12 pod: libav VAAPI (H264's backend) would misread the two-plane buffer,
|
||||
/// so H264/GameStream/PyroWave sessions must never see NV12 frames.
|
||||
pub native_nv12_session: bool,
|
||||
/// The PyroWave encoder's Vulkan-importable dmabuf modifiers for the capture's packed-RGB fourcc,
|
||||
/// resolved when the session encodes PyroWave (the passthrough advertises them so Mutter+NVIDIA,
|
||||
/// which allocates tiled-only, still negotiates zero-copy). Empty otherwise.
|
||||
|
||||
@@ -299,29 +299,11 @@ fn spawn_pipewire(
|
||||
|
||||
impl Capturer for PortalCapturer {
|
||||
fn next_frame(&mut self) -> Result<CapturedFrame> {
|
||||
// First frame can lag behind format negotiation; later frames arrive at ~fps. Wait in
|
||||
// short slices so a GPU-import poison (worker death) fails the capture within ~0.5 s
|
||||
// instead of sitting out the full first-frame budget.
|
||||
let deadline = std::time::Instant::now() + Duration::from_secs(10);
|
||||
loop {
|
||||
if self.broken.load(Ordering::Relaxed) {
|
||||
return Err(anyhow!(
|
||||
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
||||
failed repeatedly — rebuilding capture",
|
||||
self.node_id
|
||||
));
|
||||
}
|
||||
if let Some(f) = self.pending.take() {
|
||||
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
|
||||
}
|
||||
let slice = Duration::from_millis(500)
|
||||
.min(deadline.saturating_duration_since(std::time::Instant::now()));
|
||||
match self.frames.recv_timeout(slice) {
|
||||
Ok(frame) => return Ok(frame),
|
||||
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
|
||||
Err(e) => return self.next_frame_timed_out(e),
|
||||
}
|
||||
}
|
||||
self.frame_within(Duration::from_secs(10))
|
||||
}
|
||||
|
||||
fn next_frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
||||
self.frame_within(budget)
|
||||
}
|
||||
|
||||
fn supports_arrival_wait(&self) -> bool {
|
||||
@@ -417,9 +399,41 @@ impl Capturer for PortalCapturer {
|
||||
}
|
||||
|
||||
impl PortalCapturer {
|
||||
/// The [`Capturer::next_frame`] budget expired (or the thread ended) — turn it into the
|
||||
/// diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
||||
fn next_frame_timed_out(&self, err: RecvTimeoutError) -> Result<CapturedFrame> {
|
||||
/// The blocking first-frame wait behind [`Capturer::next_frame`] /
|
||||
/// [`Capturer::next_frame_within`]. First frame can lag behind format negotiation; later
|
||||
/// frames arrive at ~fps. Wait in short slices so a GPU-import poison (worker death) fails
|
||||
/// the capture within ~0.5 s instead of sitting out the full first-frame budget.
|
||||
fn frame_within(&mut self, budget: Duration) -> Result<CapturedFrame> {
|
||||
let deadline = std::time::Instant::now() + budget;
|
||||
loop {
|
||||
if self.broken.load(Ordering::Relaxed) {
|
||||
return Err(anyhow!(
|
||||
"zero-copy GPU import lost (node {}): the import worker died or tiled imports \
|
||||
failed repeatedly — rebuilding capture",
|
||||
self.node_id
|
||||
));
|
||||
}
|
||||
if let Some(f) = self.pending.take() {
|
||||
return Ok(f); // a wait_arrival stash outranks the channel (it's older)
|
||||
}
|
||||
let slice = Duration::from_millis(500)
|
||||
.min(deadline.saturating_duration_since(std::time::Instant::now()));
|
||||
match self.frames.recv_timeout(slice) {
|
||||
Ok(frame) => return Ok(frame),
|
||||
Err(RecvTimeoutError::Timeout) if std::time::Instant::now() < deadline => continue,
|
||||
Err(e) => return self.next_frame_timed_out(e, budget),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The [`frame_within`](Self::frame_within) budget expired (or the thread ended) — turn it
|
||||
/// into the diagnosis-bearing error. Split out of the slicing loop above; behavior unchanged.
|
||||
fn next_frame_timed_out(
|
||||
&self,
|
||||
err: RecvTimeoutError,
|
||||
budget: Duration,
|
||||
) -> Result<CapturedFrame> {
|
||||
let within = budget.as_secs_f32();
|
||||
match err {
|
||||
RecvTimeoutError::Timeout => {
|
||||
// Split the two black-screen root causes apart so the operator gets a cause, not
|
||||
@@ -427,9 +441,10 @@ impl PortalCapturer {
|
||||
// not (no acceptable format / node never emitted a param)?
|
||||
if self.negotiated.load(Ordering::Relaxed) {
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within 10s (node {}): format negotiated but no buffers \
|
||||
arrived — the compositor produced no frames (virtual output idle/unmapped, \
|
||||
or capture never started)",
|
||||
"no PipeWire frame within {within}s (node {}): format negotiated but no \
|
||||
buffers arrived — the compositor produced no frames (virtual output \
|
||||
idle/unmapped, capture never started, or a stream bound during a \
|
||||
compositor (re)start that will never deliver — a reconnect fixes that)",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.hdr_offer {
|
||||
@@ -440,10 +455,10 @@ impl PortalCapturer {
|
||||
// auto-reconnects) negotiates SDR instead of re-running this same timeout.
|
||||
super::note_hdr_capture_failed();
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||
the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored monitor in \
|
||||
HDR mode on GNOME 50+? Downgrading this host to SDR capture; reconnect \
|
||||
to stream SDR",
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the HDR (10-bit PQ/BT.2020 dmabuf) offer — is the mirrored \
|
||||
monitor in HDR mode on GNOME 50+? Downgrading this host to SDR capture; \
|
||||
reconnect to stream SDR",
|
||||
self.node_id
|
||||
))
|
||||
} else if self.vaapi_dmabuf && !pf_zerocopy::vaapi_dmabuf_forced() {
|
||||
@@ -452,14 +467,15 @@ impl PortalCapturer {
|
||||
// retries on the CPU offer instead of failing this same negotiation forever.
|
||||
pf_zerocopy::note_vaapi_dmabuf_failed();
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within 10s (node {}): the compositor never accepted \
|
||||
the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this host to the \
|
||||
CPU capture path; the pipeline rebuild will renegotiate without dmabuf",
|
||||
"no PipeWire frame within {within}s (node {}): the compositor never \
|
||||
accepted the LINEAR-dmabuf offer (VAAPI zero-copy) — downgrading this \
|
||||
host to the CPU capture path; the pipeline rebuild will renegotiate \
|
||||
without dmabuf",
|
||||
self.node_id
|
||||
))
|
||||
} else {
|
||||
Err(anyhow!(
|
||||
"no PipeWire frame within 10s (node {}): format negotiation never \
|
||||
"no PipeWire frame within {within}s (node {}): format negotiation never \
|
||||
completed — the compositor offered no format this consumer accepts \
|
||||
(pixel-format/modifier mismatch) or the node never emitted a Format param",
|
||||
self.node_id
|
||||
@@ -824,6 +840,7 @@ mod pipewire {
|
||||
VideoFormat::RGBA => PixelFormat::Rgba,
|
||||
VideoFormat::RGB => PixelFormat::Rgb,
|
||||
VideoFormat::BGR => PixelFormat::Bgr,
|
||||
VideoFormat::NV12 => PixelFormat::Nv12,
|
||||
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10; only ever negotiated by
|
||||
// the `want_hdr` offer, whose MANDATORY colorimetry props pin them to PQ/BT.2020).
|
||||
VideoFormat::xRGB_210LE => PixelFormat::X2Rgb10,
|
||||
@@ -989,10 +1006,10 @@ mod pipewire {
|
||||
.into_inner())
|
||||
}
|
||||
|
||||
/// Build a BGRx dmabuf `EnumFormat` pod advertising the EGL-importable `modifiers` as a
|
||||
/// mandatory enum Choice; the compositor fixates to one of them that it can allocate, which
|
||||
/// we read back in `param_changed`.
|
||||
/// Build a LINEAR/modifier DMA-BUF `EnumFormat` pod. Packed BGRx is the existing import path;
|
||||
/// NV12 is gamescope's producer-side RGB→YUV path (opt-in during bring-up).
|
||||
fn build_dmabuf_format(
|
||||
format: VideoFormat,
|
||||
modifiers: &[u64],
|
||||
preferred: Option<(u32, u32, u32)>,
|
||||
) -> Result<Vec<u8>> {
|
||||
@@ -1003,7 +1020,7 @@ mod pipewire {
|
||||
pw::spa::param::ParamType::EnumFormat,
|
||||
pw::spa::pod::property!(FormatProperties::MediaType, Id, MediaType::Video),
|
||||
pw::spa::pod::property!(FormatProperties::MediaSubtype, Id, MediaSubtype::Raw),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, VideoFormat::BGRx),
|
||||
pw::spa::pod::property!(FormatProperties::VideoFormat, Id, format),
|
||||
pw::spa::pod::property!(
|
||||
FormatProperties::VideoSize,
|
||||
Choice,
|
||||
@@ -1032,6 +1049,22 @@ mod pipewire {
|
||||
pw::spa::utils::Fraction { num: 240, denom: 1 }
|
||||
),
|
||||
);
|
||||
if format == VideoFormat::NV12 {
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorMatrix,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_MATRIX_BT709,
|
||||
)),
|
||||
});
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_colorRange,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
value: pw::spa::pod::Value::Id(pw::spa::utils::Id(
|
||||
pw::spa::sys::SPA_VIDEO_COLOR_RANGE_16_235,
|
||||
)),
|
||||
});
|
||||
}
|
||||
obj.properties.push(pw::spa::pod::Property {
|
||||
key: pw::spa::sys::SPA_FORMAT_VIDEO_modifier,
|
||||
flags: pw::spa::pod::PropertyFlags::MANDATORY,
|
||||
@@ -1604,8 +1637,8 @@ mod pipewire {
|
||||
}
|
||||
}
|
||||
|
||||
// VAAPI zero-copy passthrough: hand the raw dmabuf straight to the encoder, which imports
|
||||
// it into a VA surface and does RGB→NV12 on the GPU video engine. No CUDA importer here.
|
||||
// Raw DMA-BUF passthrough: packed RGB is imported for GPU CSC; producer-native NV12 can
|
||||
// be consumed by the Vulkan Video encoder without another color conversion.
|
||||
if ud.vaapi_passthrough {
|
||||
if let Some(fmt) = ud.format {
|
||||
if datas[0].type_() == pw::spa::buffer::DataType::DmaBuf {
|
||||
@@ -1613,9 +1646,41 @@ mod pipewire {
|
||||
let chunk = datas[0].chunk();
|
||||
let offset = chunk.offset();
|
||||
let stride = chunk.stride().max(0) as u32;
|
||||
// Native NV12 usually arrives as a two-plane SPA buffer over ONE buffer
|
||||
// object; plane 1's chunk carries the REAL UV offset/stride (compositors
|
||||
// may align the Y plane before UV). Pass it through instead of assuming
|
||||
// contiguity. Each spa_data holds its own (dup'd) fd, so BO identity is
|
||||
// by inode, not fd number; a genuinely two-BO frame cannot travel through
|
||||
// the single-fd import — drop it with a diagnosis instead of streaming
|
||||
// garbage chroma.
|
||||
let plane1 =
|
||||
if fmt == PixelFormat::Nv12 && datas.len() >= 2 && datas[1].fd() > 0 {
|
||||
// SAFETY: zeroed `libc::stat` is a valid POD initializer; both fds are
|
||||
// owned by the live PipeWire buffer for this callback, and `fstat`
|
||||
// only writes the out-param structs, whose fields are read only after
|
||||
// the `== 0` success checks.
|
||||
let same_bo = unsafe {
|
||||
let mut s0: libc::stat = std::mem::zeroed();
|
||||
let mut s1: libc::stat = std::mem::zeroed();
|
||||
libc::fstat(datas[0].fd() as i32, &mut s0) == 0
|
||||
&& libc::fstat(datas[1].fd() as i32, &mut s1) == 0
|
||||
&& (s0.st_dev, s0.st_ino) == (s1.st_dev, s1.st_ino)
|
||||
};
|
||||
if !same_bo {
|
||||
warn_once(
|
||||
"NV12 planes live in different buffer objects — frames \
|
||||
dropped (single-fd import only)",
|
||||
);
|
||||
return;
|
||||
}
|
||||
let c1 = datas[1].chunk();
|
||||
Some((c1.offset(), c1.stride().max(0) as u32))
|
||||
} else {
|
||||
None
|
||||
};
|
||||
// dup the fd so it survives the SPA buffer recycle — the encode thread
|
||||
// imports it. (Content stability across the brief map+CSC window relies on
|
||||
// the compositor's buffer-pool depth, like any zero-copy capture.)
|
||||
// imports it. Content stability across the brief import/encode window relies
|
||||
// on the compositor's buffer-pool depth, like any zero-copy capture.
|
||||
// SAFETY: `datas[0].fd()` is the dmabuf fd owned by the live PipeWire buffer (valid
|
||||
// for this callback). `fcntl(fd, F_DUPFD_CLOEXEC, 0)` reads only the integer fd,
|
||||
// touches no Rust memory, and returns a fresh independent CLOEXEC duplicate (or -1).
|
||||
@@ -1642,9 +1707,10 @@ mod pipewire {
|
||||
modifier: ud.modifier,
|
||||
offset,
|
||||
stride,
|
||||
plane1,
|
||||
}),
|
||||
// Cursor-as-metadata: the encoder blends this into its owned VA
|
||||
// surface (raw dmabuf never touched).
|
||||
// Cursor-as-metadata is blended only by RGB→NV12 backends. Gamescope
|
||||
// embeds its pointer in the produced pixels, so native NV12 has none.
|
||||
cursor: ud.cursor.overlay(),
|
||||
});
|
||||
static ONCE: std::sync::atomic::AtomicBool =
|
||||
@@ -1655,7 +1721,12 @@ mod pipewire {
|
||||
h,
|
||||
modifier = ud.modifier,
|
||||
fourcc = format_args!("{:#010x}", fourcc),
|
||||
"zero-copy: handing the raw dmabuf to the encoder (GPU import + CSC)"
|
||||
source = if fmt == PixelFormat::Nv12 {
|
||||
"producer-native NV12"
|
||||
} else {
|
||||
"packed RGB (encoder GPU CSC)"
|
||||
},
|
||||
"zero-copy: handing the raw DMA-BUF to the encoder"
|
||||
);
|
||||
}
|
||||
return;
|
||||
@@ -2015,6 +2086,28 @@ mod pipewire {
|
||||
// PyroWave session (the wavelet encoder's own Vulkan device, any vendor) → hand the raw
|
||||
// dmabuf straight to the encoder.
|
||||
let vaapi_passthrough = zerocopy && !force_shm && importer.is_none() && raw_passthrough;
|
||||
// Producer-side NV12 (default-on; PUNKTFUNK_PIPEWIRE_NV12=0 escapes): gamescope offers a
|
||||
// one-fd LINEAR NV12 image when the consumer asks — its compositor pass does the RGB→YUV,
|
||||
// and the Vulkan Video encoder imports the buffer as its encode source directly (no host
|
||||
// CSC at all). `native_nv12_session` restricts this to sessions whose encoder can ingest
|
||||
// it (Linux vulkan-encode H265/AV1 — never H264/libav-VAAPI, GameStream-resolve, or
|
||||
// PyroWave, whose Vulkan compute CSC ingests packed RGB only). Raw passthrough is
|
||||
// required because the CUDA importer expects packed RGB, and 4:4:4/HDR must not be
|
||||
// silently subsampled/downconverted. Non-NV12 compositors (KWin/GNOME) simply match the
|
||||
// packed-RGB fallback pod.
|
||||
let prefer_native_nv12 = std::env::var("PUNKTFUNK_PIPEWIRE_NV12").as_deref() != Ok("0")
|
||||
&& policy.native_nv12_session
|
||||
&& backend_is_vaapi
|
||||
&& vaapi_passthrough
|
||||
&& !policy.pyrowave_session
|
||||
&& !want_444
|
||||
&& !want_hdr;
|
||||
if prefer_native_nv12 {
|
||||
tracing::info!(
|
||||
"zero-copy: preferring gamescope producer-side NV12 LINEAR DMA-BUF (no host \
|
||||
RGB CSC; PUNKTFUNK_PIPEWIRE_NV12=0 restores the packed-RGB negotiation)"
|
||||
);
|
||||
}
|
||||
// Modifiers our import stack handles for BGRx: the EGL-importable (tiled) set, plus LINEAR
|
||||
// (0) — NVIDIA's EGL won't list it, but LINEAR dmabufs (gamescope's only offer) import via
|
||||
// CUDA external memory instead. For the VAAPI passthrough path we advertise LINEAR only:
|
||||
@@ -2054,7 +2147,9 @@ mod pipewire {
|
||||
tracing::warn!("zero-copy: no importable dmabuf modifiers — using CPU path");
|
||||
} else if vaapi_passthrough && policy.pyrowave_modifiers.is_empty() {
|
||||
tracing::info!(
|
||||
"zero-copy: advertising LINEAR dmabuf for direct VAAPI import (GPU CSC)"
|
||||
native_nv12_preferred = prefer_native_nv12,
|
||||
"zero-copy: advertising LINEAR DMA-BUF for encoder import (native NV12 first \
|
||||
when enabled, packed RGB fallback)"
|
||||
);
|
||||
} else if want_dmabuf && !vaapi_passthrough {
|
||||
tracing::info!(
|
||||
@@ -2394,7 +2489,18 @@ mod pipewire {
|
||||
build_hdr_dmabuf_format(VideoFormat::xBGR_210LE, preferred)?,
|
||||
]
|
||||
} else if want_dmabuf {
|
||||
vec![build_dmabuf_format(&modifiers, preferred)?]
|
||||
let mut pods = Vec::with_capacity(if prefer_native_nv12 { 2 } else { 1 });
|
||||
if prefer_native_nv12 {
|
||||
// First compatible consumer pod wins. Gamescope advertises NV12 and BGRx; pinning
|
||||
// BT.709 limited here selects its RGB→NV12 shader with our bitstream colorimetry.
|
||||
pods.push(build_dmabuf_format(VideoFormat::NV12, &[0], preferred)?);
|
||||
}
|
||||
pods.push(build_dmabuf_format(
|
||||
VideoFormat::BGRx,
|
||||
&modifiers,
|
||||
preferred,
|
||||
)?);
|
||||
pods
|
||||
} else {
|
||||
vec![serialize_pod(obj)?]
|
||||
};
|
||||
|
||||
@@ -308,8 +308,9 @@ float2 main(float4 pos : SV_POSITION, float2 uv : TEXCOORD0) : SV_TARGET {
|
||||
/// Plane writes use per-plane render-target views of the single P010 texture: an `R16_UNORM` RTV
|
||||
/// selects plane 0 (luma, full WxH), an `R16G16_UNORM` RTV selects plane 1 (chroma, W/2 x H/2). This
|
||||
/// planar-RTV mechanism needs a D3D11.3+ runtime + driver support; [`HdrP010Converter::convert`]
|
||||
/// surfaces a clear error if `CreateRenderTargetView` rejects the plane format so the caller can fall
|
||||
/// back to the existing R10 path.
|
||||
/// surfaces a clear error if `CreateRenderTargetView` rejects the plane format. (There is no runtime
|
||||
/// fallback — the error propagates through `try_consume` and ends the session; the "R10 path" the
|
||||
/// original design referenced was never kept.)
|
||||
pub(crate) struct HdrP010Converter {
|
||||
vs: ID3D11VertexShader,
|
||||
ps_y: ID3D11PixelShader,
|
||||
@@ -737,14 +738,157 @@ fn p010_reference(r: f64, g: f64, b: f64) -> (f64, f64, f64) {
|
||||
/// Y ≤ 4 codes, U/V ≤ 5 codes (rounding + chroma averaging). Prints a per-colour table + PASS/FAIL.
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn hdr_p010_selftest() -> Result<()> {
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_HARDWARE;
|
||||
use windows::Win32::Graphics::Dxgi::IDXGIAdapter;
|
||||
hdr_p010_selftest_at(64, 64, None)
|
||||
}
|
||||
|
||||
// 64x64, even dims. A 4x4 grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform →
|
||||
// exact chroma comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus
|
||||
// a couple of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||
const W: u32 = 64;
|
||||
const H: u32 = 64;
|
||||
/// [`hdr_p010_selftest`] at an arbitrary even size and (optionally) on a specific GPU vendor
|
||||
/// (PCI vendor id, e.g. `0x8086` Intel / `0x10de` NVIDIA / `0x1002` AMD). The size matters on
|
||||
/// top of the 64×64 default because the field sessions run at capture resolutions whose height
|
||||
/// is NOT 16-aligned (1080 → the encoder's align16 pool seam) and a driver may treat the planar
|
||||
/// RTVs differently at real sizes; the vendor pin matters on dual-GPU boxes where the default
|
||||
/// adapter is not the one the session encodes on.
|
||||
/// Test support (used by pf-encode's live e2e): the 8 sRGB colour bars (white/yellow/cyan/green/
|
||||
/// magenta/red/blue/black, sRGB 1.0 = scRGB 1.0 = 80 nits) as a w×h FP16 scRGB texture on the
|
||||
/// adapter with `luid`, converted through the REAL [`HdrP010Converter`] into a P010 texture with
|
||||
/// **`BIND_RENDER_TARGET` only, `MiscFlags` 0 — the exact bind profile of the IDD out-ring** (the
|
||||
/// CPU-upload encoder tests can't use that profile, so only this path exercises "RTV-written P010
|
||||
/// → encoder ingest copy"). Returns `(device, p010)`; expected decoded codes per bar are the
|
||||
/// bars_pq2020 fixture's: (490,512,512) (478,423,518) (464,525,473) (450,432,476) (350,584,585)
|
||||
/// (325,448,598) (226,650,535) (64,512,512).
|
||||
#[cfg(target_os = "windows")]
|
||||
#[doc(hidden)]
|
||||
pub fn hdr_p010_convert_bars_on_luid(
|
||||
luid: [u8; 8],
|
||||
w: u32,
|
||||
h: u32,
|
||||
) -> Result<(ID3D11Device, ID3D11Texture2D)> {
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("bars pattern needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// sRGB primaries at full/zero channels: sRGB EOTF(1.0)=1.0, (0)=0 → the scRGB pattern is
|
||||
// pure 0/1 floats and the PQ/BT.2020 reference codes above are exact.
|
||||
const BARS: [(f32, f32, f32); 8] = [
|
||||
(1.0, 1.0, 1.0),
|
||||
(1.0, 1.0, 0.0),
|
||||
(0.0, 1.0, 1.0),
|
||||
(0.0, 1.0, 0.0),
|
||||
(1.0, 0.0, 1.0),
|
||||
(1.0, 0.0, 0.0),
|
||||
(0.0, 0.0, 1.0),
|
||||
(0.0, 0.0, 0.0),
|
||||
];
|
||||
let bar_w = (w / 8).max(1) as usize;
|
||||
let mut fp16 = vec![0u16; (w * h * 4) as usize];
|
||||
for y in 0..h as usize {
|
||||
for x in 0..w as usize {
|
||||
let (r, g, b) = BARS[(x / bar_w).min(7)];
|
||||
let i = (y * w as usize + x) * 4;
|
||||
fp16[i] = f32_to_f16(r);
|
||||
fp16[i + 1] = f32_to_f16(g);
|
||||
fp16[i + 2] = f32_to_f16(b);
|
||||
fp16[i + 3] = f32_to_f16(1.0);
|
||||
}
|
||||
}
|
||||
// SAFETY: same single-device/single-thread contract as `hdr_p010_selftest_at`; the FP16
|
||||
// initial-data Vec outlives the synchronous CreateTexture2D; the returned COM handles own
|
||||
// their references.
|
||||
unsafe {
|
||||
let luid = windows::Win32::Foundation::LUID {
|
||||
LowPart: u32::from_le_bytes(luid[..4].try_into().unwrap()),
|
||||
HighPart: i32::from_le_bytes(luid[4..].try_into().unwrap()),
|
||||
};
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).context("adapter by luid")?;
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
&adapter,
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
Some(&mut context),
|
||||
)
|
||||
.context("D3D11CreateDevice(luid) for bars convert")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_R16G16B16A16_FLOAT,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_SHADER_RESOURCE.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let init = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: fp16.as_ptr() as *const c_void,
|
||||
SysMemPitch: w * 8,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut src_tex: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&src_desc, Some(&init), Some(&mut src_tex))
|
||||
.context("CreateTexture2D(fp16 bars)")?;
|
||||
let src_tex = src_tex.context("null src tex")?;
|
||||
let mut src_srv: Option<ID3D11ShaderResourceView> = None;
|
||||
device
|
||||
.CreateShaderResourceView(&src_tex, None, Some(&mut src_srv))
|
||||
.context("CreateShaderResourceView(fp16 bars)")?;
|
||||
let src_srv = src_srv.context("null src srv")?;
|
||||
|
||||
// The IDD out-ring's exact profile: P010, RENDER_TARGET only, MiscFlags 0.
|
||||
let p010_desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: w,
|
||||
Height: h,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: D3D11_BIND_RENDER_TARGET.0 as u32,
|
||||
..Default::default()
|
||||
};
|
||||
let mut p010: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&p010_desc, None, Some(&mut p010))
|
||||
.context("CreateTexture2D(P010 bars dst)")?;
|
||||
let p010 = p010.context("null p010 tex")?;
|
||||
|
||||
let conv = HdrP010Converter::new(&device)?;
|
||||
conv.convert(&device, &context, &src_srv, &p010, w, h)?;
|
||||
Ok((device, p010))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(target_os = "windows")]
|
||||
pub fn hdr_p010_selftest_at(w: u32, h: u32, vendor: Option<u32>) -> Result<()> {
|
||||
use windows::Win32::Graphics::Direct3D::{D3D_DRIVER_TYPE_HARDWARE, D3D_DRIVER_TYPE_UNKNOWN};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter, IDXGIFactory1};
|
||||
|
||||
if w == 0 || h == 0 || w % 2 != 0 || h % 2 != 0 {
|
||||
bail!("hdr-p010-selftest needs even non-zero dimensions, got {w}x{h}");
|
||||
}
|
||||
// A grid of 16x16 flat scRGB blocks (each 2x2 chroma footprint uniform → exact chroma
|
||||
// comparison) covering pure R/G/B/white/black/gray at plausible HDR nit levels, plus a couple
|
||||
// of bright (>1.0 scRGB) colours, then the rest is a gradient (compared on Y only).
|
||||
#[allow(non_snake_case)]
|
||||
let (W, H) = (w, h);
|
||||
const BLK: u32 = 16;
|
||||
// (name, r, g, b) scRGB linear (1.0 = 80 nits). Mix of SDR-ish and HDR (>1.0) values.
|
||||
let named: [(&str, f32, f32, f32); 8] = [
|
||||
@@ -797,12 +941,36 @@ pub fn hdr_p010_selftest() -> Result<()> {
|
||||
// `fp16` outlives the synchronous `CreateTexture2D` that reads it. The mapped-pointer reads are
|
||||
// proven individually at the `read_u16` closure below.
|
||||
unsafe {
|
||||
// Hardware D3D11 device (no adapter pin — the default GPU is fine for the self-test).
|
||||
// Device on the requested vendor's adapter (dual-GPU boxes encode on a specific one), else
|
||||
// the default hardware GPU. Always says which adapter ran — a PASS is only meaningful for
|
||||
// the GPU it actually tested.
|
||||
let adapter: Option<IDXGIAdapter> = match vendor {
|
||||
None => None,
|
||||
Some(want) => {
|
||||
let factory: IDXGIFactory1 = CreateDXGIFactory1().context("dxgi factory")?;
|
||||
let mut found = None;
|
||||
for i in 0.. {
|
||||
let Ok(a) = factory.EnumAdapters(i) else {
|
||||
break;
|
||||
};
|
||||
let desc = a.GetDesc().context("adapter desc")?;
|
||||
if desc.VendorId == want {
|
||||
found = Some(a);
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(found.with_context(|| format!("no adapter with vendor id {want:#x}"))?)
|
||||
}
|
||||
};
|
||||
let mut device: Option<ID3D11Device> = None;
|
||||
let mut context: Option<ID3D11DeviceContext> = None;
|
||||
D3D11CreateDevice(
|
||||
None::<&IDXGIAdapter>,
|
||||
D3D_DRIVER_TYPE_HARDWARE,
|
||||
adapter.as_ref(),
|
||||
if adapter.is_some() {
|
||||
D3D_DRIVER_TYPE_UNKNOWN
|
||||
} else {
|
||||
D3D_DRIVER_TYPE_HARDWARE
|
||||
},
|
||||
HMODULE::default(),
|
||||
D3D11_CREATE_DEVICE_BGRA_SUPPORT,
|
||||
Some(&[D3D_FEATURE_LEVEL_11_0]),
|
||||
@@ -814,6 +982,22 @@ pub fn hdr_p010_selftest() -> Result<()> {
|
||||
.context("D3D11CreateDevice(hardware) for hdr-p010-selftest")?;
|
||||
let device = device.context("null device")?;
|
||||
let context = context.context("null context")?;
|
||||
{
|
||||
let dxgi: windows::Win32::Graphics::Dxgi::IDXGIDevice =
|
||||
device.cast().context("device -> IDXGIDevice")?;
|
||||
let desc = dxgi.GetAdapter().context("GetAdapter")?.GetDesc()?;
|
||||
let name = String::from_utf16_lossy(
|
||||
&desc.Description[..desc
|
||||
.Description
|
||||
.iter()
|
||||
.position(|&c| c == 0)
|
||||
.unwrap_or(desc.Description.len())],
|
||||
);
|
||||
println!(
|
||||
"adapter: {name} (vendor {:#06x}, luid {:08x}:{:08x})",
|
||||
desc.VendorId, desc.AdapterLuid.HighPart, desc.AdapterLuid.LowPart
|
||||
);
|
||||
}
|
||||
|
||||
// Source FP16 texture (initialized) + SRV.
|
||||
let src_desc = D3D11_TEXTURE2D_DESC {
|
||||
@@ -1175,3 +1359,16 @@ impl VideoConverter {
|
||||
blt.context("VideoProcessorBlt")
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod hdr_selftests {
|
||||
/// LIVE (needs the GPU): [`super::hdr_p010_selftest_at`] at the field capture size — 1080 is
|
||||
/// NOT 16-aligned, and the planar-RTV write path is driver-specific per vendor. Pinned to the
|
||||
/// Intel adapter (`0x8086`), so it runs on the Intel validation boxes and errors out cleanly
|
||||
/// ("no adapter") elsewhere. `cargo test -p pf-capture -- --ignored hdr_p010 --nocapture`.
|
||||
#[test]
|
||||
#[ignore]
|
||||
fn hdr_p010_selftest_intel_1080_live() {
|
||||
super::hdr_p010_selftest_at(1920, 1080, Some(0x8086)).expect("hdr p010 selftest @1080");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -25,6 +25,11 @@ tracing = "0.1"
|
||||
# A test writer for the NVENC backend's unit tests (`with_test_writer().try_init()`).
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
[target.'cfg(target_os = "windows")'.dev-dependencies]
|
||||
# The QSV live e2e drives the REAL HdrP010Converter output (an RTV-written, ring-profile P010
|
||||
# texture) into the encoder — the one seam the CPU-upload tests can't reach.
|
||||
pf-capture = { path = "../pf-capture" }
|
||||
|
||||
[target.'cfg(any(target_os = "linux", target_os = "windows"))'.dependencies]
|
||||
# Software H.264 (openh264, BSD-2) — the GPU-less encode path on both platforms.
|
||||
openh264 = "0.9"
|
||||
|
||||
@@ -37,9 +37,11 @@ pub(crate) fn fourcc_to_vk(fourcc: u32) -> Option<vk::Format> {
|
||||
const AR24: u32 = 0x3432_5241; // ARGB8888
|
||||
const XB24: u32 = 0x3432_4258; // XBGR8888
|
||||
const AB24: u32 = 0x3432_4241; // ABGR8888
|
||||
const NV12: u32 = 0x3231_564e; // DRM_FORMAT_NV12
|
||||
match fourcc {
|
||||
XR24 | AR24 => Some(vk::Format::B8G8R8A8_UNORM),
|
||||
XB24 | AB24 => Some(vk::Format::R8G8B8A8_UNORM),
|
||||
NV12 => Some(vk::Format::G8_B8R8_2PLANE_420_UNORM),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
@@ -90,9 +92,10 @@ pub(crate) unsafe fn import_rgb_dmabuf(
|
||||
)
|
||||
}
|
||||
|
||||
/// [`import_rgb_dmabuf`] with the image usage explicit and an optional video-profile list
|
||||
/// (chained into the image create) — the RGB-direct encode path imports the captured buffer
|
||||
/// as a profiled `VIDEO_ENCODE_SRC` image instead of a sampled one.
|
||||
/// [`import_rgb_dmabuf`] with the image usage explicit and an optional video-profile list.
|
||||
/// Despite the historical name, this also imports gamescope's one-fd LINEAR NV12: the UV
|
||||
/// subresource layout comes from the producer's plane-1 chunk when it reported one, falling
|
||||
/// back to the shared-stride contiguous-plane contract.
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
device: &ash::Device,
|
||||
@@ -108,12 +111,27 @@ pub(crate) unsafe fn import_rgb_dmabuf_as(
|
||||
use std::os::fd::IntoRawFd;
|
||||
let fmt = fourcc_to_vk(d.fourcc)
|
||||
.with_context(|| format!("unsupported dmabuf fourcc {:#x}", d.fourcc))?;
|
||||
let plane = [vk::SubresourceLayout::default()
|
||||
.offset(d.offset as u64)
|
||||
.row_pitch(d.stride as u64)];
|
||||
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,
|
||||
d.stride as u64,
|
||||
));
|
||||
vec![
|
||||
vk::SubresourceLayout::default()
|
||||
.offset(d.offset as u64)
|
||||
.row_pitch(d.stride as u64),
|
||||
vk::SubresourceLayout::default()
|
||||
.offset(uv_offset)
|
||||
.row_pitch(uv_stride),
|
||||
]
|
||||
} else {
|
||||
vec![vk::SubresourceLayout::default()
|
||||
.offset(d.offset as u64)
|
||||
.row_pitch(d.stride as u64)]
|
||||
};
|
||||
let mut drm = vk::ImageDrmFormatModifierExplicitCreateInfoEXT::default()
|
||||
.drm_format_modifier(d.modifier)
|
||||
.plane_layouts(&plane);
|
||||
.plane_layouts(&planes);
|
||||
let mut ext = vk::ExternalMemoryImageCreateInfo::default()
|
||||
.handle_types(vk::ExternalMemoryHandleTypeFlags::DMA_BUF_EXT);
|
||||
let mut ci = vk::ImageCreateInfo::default()
|
||||
|
||||
@@ -14,7 +14,7 @@ use super::vk_util::{color_range, find_mem, make_plain_image, make_view, pixel_t
|
||||
use crate::{Codec, EncodedFrame, Encoder, EncoderCaps};
|
||||
use anyhow::{bail, Context, Result};
|
||||
use ash::vk;
|
||||
use pf_frame::{CapturedFrame, FramePayload};
|
||||
use pf_frame::{CapturedFrame, FramePayload, PixelFormat};
|
||||
use std::collections::VecDeque;
|
||||
use std::ffi::c_void;
|
||||
use std::os::fd::AsRawFd;
|
||||
@@ -84,17 +84,36 @@ fn rgb_request() -> Option<bool> {
|
||||
}
|
||||
}
|
||||
|
||||
/// True-extent RGB-direct at unaligned modes (default ON; `PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0`
|
||||
/// restores the padded-copy staging): direct-import the visible-size capture with the TRUE-SIZE
|
||||
/// source `codedExtent` — RADV derives nonzero VCN firmware padding from it, so the EFC is told
|
||||
/// the source lacks the alignment rows (see [`RgbDirect::true_extent`]). Guarded-tested on Van
|
||||
/// Gogh 2026-07-21 (kernel clean, and the fastest 1080p encode path measured); the EFC only
|
||||
/// exists on Mesa ≥ 26, where the `codedExtent`-driven `session_init` is guaranteed.
|
||||
fn rgb_true_extent_request() -> bool {
|
||||
std::env::var("PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT").as_deref() != Ok("0")
|
||||
}
|
||||
|
||||
/// Live RGB-direct session config: the chroma-siting bits the session was created with
|
||||
/// (chosen from what the driver advertises — see [`probe_rgb_direct`]).
|
||||
struct RgbDirect {
|
||||
x_offset: u32, // vk_valve_rgb::CHROMA_OFFSET_*
|
||||
y_offset: u32,
|
||||
/// The mode is not 64x16-aligned, so the captured buffer cannot be the encode source
|
||||
/// directly (the EFC would read past it — the 2026-07-20 field GPU hang). Instead each
|
||||
/// frame is copied into a per-slot ALIGNED BGRA staging image with the edge rows/columns
|
||||
/// duplicated into the padding (transfer-only, no shader) and encoded from there. Aligned
|
||||
/// modes keep the true zero-copy import.
|
||||
/// under the session's ALIGNED source extent (the EFC read past it — the 2026-07-20 field
|
||||
/// GPU hang, when the source `codedExtent` was the aligned size and RADV therefore derived
|
||||
/// ZERO firmware padding). Each frame is copied into a per-slot ALIGNED BGRA staging image
|
||||
/// with the edge rows/columns duplicated into the padding (transfer-only, no shader) and
|
||||
/// encoded from there. Aligned modes keep the true zero-copy import.
|
||||
padded: bool,
|
||||
/// The default unaligned-mode source strategy (`PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0` falls
|
||||
/// back to `padded`): direct-import the visible-size buffer and pass the TRUE-SIZE source
|
||||
/// `codedExtent` — RADV then programs nonzero firmware padding from it (Mesa ≥ 24.2
|
||||
/// derives `session_init` padding from `srcPictureResource.codedExtent`; see
|
||||
/// [`VulkanVideoEncoder::native_nv12`]), telling the VCN the source lacks the alignment
|
||||
/// rows, which the hardware edge-extends internally. The session/SPS/DPB stay app-aligned.
|
||||
/// Guarded-tested on Van Gogh (kernel clean; fastest 1080p path measured).
|
||||
true_extent: bool,
|
||||
}
|
||||
|
||||
/// Stack storage for a complete rgb-chained video profile. Profiled image creation AFTER open
|
||||
@@ -155,6 +174,51 @@ impl RgbProfileStack {
|
||||
}
|
||||
}
|
||||
|
||||
/// Non-RGB video profile rebuilt for native NV12 DMA-BUF imports. Image creation after `open`
|
||||
/// must carry a profile identical by value to the session profile.
|
||||
struct NativeProfileStack {
|
||||
usage: vk::VideoEncodeUsageInfoKHR<'static>,
|
||||
h265: vk::VideoEncodeH265ProfileInfoKHR<'static>,
|
||||
av1: super::vk_av1_encode::VideoEncodeAV1ProfileInfoKHR,
|
||||
profile: vk::VideoProfileInfoKHR<'static>,
|
||||
}
|
||||
|
||||
impl NativeProfileStack {
|
||||
fn new(codec_op: vk::VideoCodecOperationFlagsKHR) -> Self {
|
||||
use super::vk_av1_encode as av1b;
|
||||
Self {
|
||||
usage: vk::VideoEncodeUsageInfoKHR::default()
|
||||
.video_usage_hints(vk::VideoEncodeUsageFlagsKHR::STREAMING)
|
||||
.video_content_hints(vk::VideoEncodeContentFlagsKHR::RENDERED)
|
||||
.tuning_mode(vk::VideoEncodeTuningModeKHR::ULTRA_LOW_LATENCY),
|
||||
h265: vk::VideoEncodeH265ProfileInfoKHR::default().std_profile_idc(
|
||||
vk::native::StdVideoH265ProfileIdc_STD_VIDEO_H265_PROFILE_IDC_MAIN,
|
||||
),
|
||||
av1: av1b::VideoEncodeAV1ProfileInfoKHR {
|
||||
s_type: av1b::stype(av1b::ST_PROFILE_INFO),
|
||||
p_next: std::ptr::null(),
|
||||
std_profile: vk::native::StdVideoAV1Profile_STD_VIDEO_AV1_PROFILE_MAIN,
|
||||
},
|
||||
profile: vk::VideoProfileInfoKHR::default()
|
||||
.video_codec_operation(codec_op)
|
||||
.chroma_subsampling(vk::VideoChromaSubsamplingFlagsKHR::TYPE_420)
|
||||
.luma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8)
|
||||
.chroma_bit_depth(vk::VideoComponentBitDepthFlagsKHR::TYPE_8),
|
||||
}
|
||||
}
|
||||
|
||||
fn wire(&mut self, av1: bool) -> &vk::VideoProfileInfoKHR<'static> {
|
||||
if av1 {
|
||||
self.av1.p_next = &self.usage as *const _ as *const c_void;
|
||||
self.profile.p_next = &self.av1 as *const _ as *const c_void;
|
||||
} else {
|
||||
self.h265.p_next = &self.usage as *const _ as *const c_void;
|
||||
self.profile.p_next = &self.h265 as *const _ as *const c_void;
|
||||
}
|
||||
&self.profile
|
||||
}
|
||||
}
|
||||
|
||||
/// The Vulkan codec-operation bit for our codec selection (shared by open and the per-import
|
||||
/// profile rebuilds — the two must agree, profile identity is by value).
|
||||
fn codec_op_for(av1: bool) -> vk::VideoCodecOperationFlagsKHR {
|
||||
@@ -173,12 +237,12 @@ enum SrcAcquire {
|
||||
/// CSC path: `nv12_src` was written by this frame's compute batch (GENERAL layout; the
|
||||
/// csc_sem orders the queues).
|
||||
CscGeneral,
|
||||
/// RGB-direct, first use of a dmabuf import: acquire from the foreign producer
|
||||
/// (UNDEFINED preserves the modifier-tiled bytes) with a FOREIGN→encode-family transfer.
|
||||
RgbFresh,
|
||||
/// RGB-direct, cached import: the image is already VIDEO_ENCODE_SRC; visibility-only
|
||||
/// barrier for the producer's out-of-band rewrite of the bytes.
|
||||
RgbCached,
|
||||
/// First use of a DMA-BUF imported directly as the video source: acquire from the foreign
|
||||
/// producer (UNDEFINED preserves modifier-backed bytes) with a FOREIGN→encode-family transfer.
|
||||
DmabufFresh,
|
||||
/// Cached direct-source import: already VIDEO_ENCODE_SRC; visibility-only barrier for the
|
||||
/// producer's out-of-band rewrite of the bytes.
|
||||
DmabufCached,
|
||||
/// RGB-direct CPU upload: the compute queue copied the staging buffer in (semaphore
|
||||
/// ordered); transition TRANSFER_DST → VIDEO_ENCODE_SRC.
|
||||
CpuUpload,
|
||||
@@ -376,18 +440,33 @@ pub struct VulkanVideoEncoder {
|
||||
/// `ENCODE_QUALITY_LEVEL` control and baked into the session-parameters object (the spec
|
||||
/// requires the two to match).
|
||||
quality_level: u32,
|
||||
/// `PUNKTFUNK_PERF` CSC/encode split: >0 ⇒ per-frame GPU timestamps are recorded around the
|
||||
/// compute batch and a sampled `csc_us` line is logged; the value is the device timestamp
|
||||
/// period in ns/tick. 0.0 ⇒ off (env unset, or the compute family has no timestamp support).
|
||||
/// `PUNKTFUNK_PERF` pre-encode split: >0 ⇒ per-frame GPU timestamps bracket either the
|
||||
/// RGB→NV12 compute batch or the native-NV12 padded copy. The measured duration is logged
|
||||
/// separately from the host's fence wait; 0.0 means disabled or unsupported.
|
||||
ts_period_ns: f64,
|
||||
perf_at: std::time::Instant, // last sampled csc_us log (2 s cadence)
|
||||
perf_at: std::time::Instant,
|
||||
/// 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
|
||||
/// into the video session).
|
||||
rgb: Option<RgbDirect>,
|
||||
/// One-shot warning latch: a cursor bitmap arrived on an RGB-direct session (EFC cannot
|
||||
/// composite it — the cursor will be missing from the stream until the CSC path is used).
|
||||
/// Producer supplied native NV12 rather than packed RGB. EVERY mode encodes the imported
|
||||
/// visible-size buffer directly — safely, because native sessions use TRUE-SIZE headers:
|
||||
/// the SPS/sequence header is authored at the render size and every picture resource's
|
||||
/// `codedExtent` matches it, so RADV programs the VCN with `session_init` extent = the true
|
||||
/// size and a nonzero `padding_width/height`, and the FIRMWARE edge-extends the alignment
|
||||
/// padding internally (radv_video_enc.c `radv_enc_session_init`; the driver also rounds the
|
||||
/// bitstream SPS up itself and compensates with a conformance window —
|
||||
/// `radv_video_patch_encode_session_parameters`, per the VK_KHR_video_encode_h265 proposal's
|
||||
/// "implementations may override" clause). The source is never read past its extent — unlike
|
||||
/// the app-aligned-SPS convention the CSC/RGB paths use, where the coded extent is 64x16-
|
||||
/// aligned and an undersized direct source is the OOB-read class behind the 2026-07-20 field
|
||||
/// GPU reset (those paths keep their aligned-size sources/staging).
|
||||
native_nv12: bool,
|
||||
|
||||
/// One-shot warning latch: a cursor bitmap arrived on an RGB-direct or native-NV12 session
|
||||
/// (neither has a compositing stage — the cursor will be missing from the stream until the
|
||||
/// CSC path is used).
|
||||
warned_cursor: bool,
|
||||
/// A [`reconfigure_bitrate`](Encoder::reconfigure_bitrate) rate not yet installed in the video
|
||||
/// session. The next `record_submit` emits an `ENCODE_RATE_CONTROL` control command carrying it
|
||||
@@ -422,14 +501,24 @@ impl VulkanVideoEncoder {
|
||||
/// (B2). `PUNKTFUNK_VULKAN_RGB_DIRECT` overrides both ways (see [`rgb_request`]).
|
||||
pub fn open(
|
||||
codec: Codec,
|
||||
format: PixelFormat,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
cursor_blend: bool,
|
||||
) -> Result<Self> {
|
||||
let want_rgb = rgb_request().unwrap_or(!cursor_blend);
|
||||
Self::open_opts(codec, width, height, fps, bitrate_bps, want_rgb)
|
||||
let native_nv12 = format == PixelFormat::Nv12;
|
||||
let want_rgb = !native_nv12 && rgb_request().unwrap_or(!cursor_blend);
|
||||
Self::open_opts_inner(
|
||||
codec,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
bitrate_bps,
|
||||
want_rgb,
|
||||
native_nv12,
|
||||
)
|
||||
}
|
||||
|
||||
/// `open` with the RGB-direct request explicit instead of read from the env — the smoke
|
||||
@@ -443,6 +532,18 @@ impl VulkanVideoEncoder {
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
want_rgb: bool,
|
||||
) -> Result<Self> {
|
||||
Self::open_opts_inner(codec, width, height, fps, bitrate_bps, want_rgb, false)
|
||||
}
|
||||
|
||||
fn open_opts_inner(
|
||||
codec: Codec,
|
||||
width: u32,
|
||||
height: u32,
|
||||
fps: u32,
|
||||
bitrate_bps: u64,
|
||||
want_rgb: bool,
|
||||
native_nv12: bool,
|
||||
) -> Result<Self> {
|
||||
if !matches!(codec, Codec::H265 | Codec::Av1) {
|
||||
bail!("vulkan-encode backend supports HEVC + AV1 only (got {codec:?})");
|
||||
@@ -464,6 +565,7 @@ impl VulkanVideoEncoder {
|
||||
fps.max(1),
|
||||
bitrate_bps.max(1_000_000),
|
||||
want_rgb,
|
||||
native_nv12,
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -478,6 +580,7 @@ impl VulkanVideoEncoder {
|
||||
fps: u32,
|
||||
bitrate: u64,
|
||||
want_rgb: bool,
|
||||
native_nv12: bool,
|
||||
) -> Result<Self> {
|
||||
use super::vk_av1_encode as av1b;
|
||||
use super::vk_valve_rgb as vrgb;
|
||||
@@ -551,46 +654,63 @@ impl VulkanVideoEncoder {
|
||||
0.0
|
||||
};
|
||||
|
||||
// RGB-direct (EFC) resolution — BEFORE the profile is built, because an active rgb
|
||||
// session changes the profile identity (the rgb-conversion struct rides the chain) and
|
||||
// the session's picture format. The probe runs unconditionally: its verdict is the
|
||||
// field telemetry that decides where B2 can default this on.
|
||||
let rgb_probe = probe_rgb_direct(&instance, &vq_inst, pd, codec_op, av1);
|
||||
// ALIGNMENT GATE (field GPU-hang, 2026-07-20): the coded extent is 64x16-aligned but the
|
||||
// captured dmabuf is only the REAL mode size — handing it to the encoder as the direct
|
||||
// source makes the VCN's EFC read the alignment-padding rows PAST the end of the buffer.
|
||||
// At 1920x1080 (coded 1088) that is 8 rows = 61 KB of out-of-bounds reads per frame:
|
||||
// deterministic VM protection faults, vcn_enc ring timeouts, and — through the stall
|
||||
// watchdog's rebuild-and-refault loop — a full MODE1 GPU reset with VRAM loss. The CSC
|
||||
// shader absorbs the padding by clamping reads and duplicating the edge; RGB-direct has
|
||||
// no such stage. Mode select: an aligned mode (720p/1440p/4K) encodes the imported
|
||||
// buffer directly (true zero-copy); an unaligned one (1080p!) goes through the
|
||||
// padded-copy staging image (see [`RgbDirect::padded`]) — transfer-only, still no
|
||||
// compute CSC.
|
||||
// Resolve the encode source before building the profile: EFC RGB conversion changes
|
||||
// profile identity; producer-native NV12 uses the ordinary 4:2:0 profile.
|
||||
let aligned = rw == w && rh == h;
|
||||
let rgb_probe = if native_nv12 {
|
||||
Err("not-probed(native NV12 source selected)")
|
||||
} else {
|
||||
probe_rgb_direct(&instance, &vq_inst, pd, codec_op, av1)
|
||||
};
|
||||
let rgb_cfg: Option<RgbDirect> = match (&rgb_probe, want_rgb) {
|
||||
(Ok((x, y)), true) => Some(RgbDirect {
|
||||
x_offset: *x,
|
||||
y_offset: *y,
|
||||
padded: !aligned,
|
||||
}),
|
||||
(Ok((x, y)), true) => {
|
||||
let true_extent = !aligned && rgb_true_extent_request();
|
||||
Some(RgbDirect {
|
||||
x_offset: *x,
|
||||
y_offset: *y,
|
||||
padded: !aligned && !true_extent,
|
||||
true_extent,
|
||||
})
|
||||
}
|
||||
_ => None,
|
||||
};
|
||||
tracing::info!(
|
||||
rgb_direct = match (&rgb_probe, want_rgb, &rgb_cfg) {
|
||||
(_, _, Some(RgbDirect { padded: false, .. })) => "active",
|
||||
(_, _, Some(RgbDirect { padded: true, .. })) =>
|
||||
"active(padded-copy: mode is not 64x16-aligned — staging blit + edge \
|
||||
duplication instead of the direct import)",
|
||||
(Ok(_), false, None) =>
|
||||
"available(off: PUNKTFUNK_VULKAN_RGB_DIRECT=0, or a cursor-blend session \
|
||||
— =1 forces)",
|
||||
(Err(e), _, None) => e,
|
||||
// (Ok, wanted) always builds Some above.
|
||||
(Ok(_), true, None) => unreachable!("rgb gate and cfg disagree"),
|
||||
},
|
||||
"vulkan-encode: EFC RGB-direct encode source (design/vulkan-rgb-direct-encode.md)"
|
||||
);
|
||||
if native_nv12 {
|
||||
tracing::info!(
|
||||
native_nv12 = "active(direct-import)",
|
||||
source_width = rw,
|
||||
source_height = rh,
|
||||
fw_padding_width = w - rw,
|
||||
fw_padding_height = h - rh,
|
||||
"vulkan-encode: producer-native NV12 encode source (true-size headers: the \
|
||||
driver aligns the bitstream SPS itself and the firmware edge-extends the \
|
||||
padding — the source is never read past its extent)"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
rgb_direct = match (&rgb_probe, want_rgb, &rgb_cfg) {
|
||||
(
|
||||
_,
|
||||
_,
|
||||
Some(RgbDirect {
|
||||
true_extent: true, ..
|
||||
}),
|
||||
) =>
|
||||
"active(true-extent: unaligned mode, direct import with the true-size \
|
||||
source codedExtent — RADV firmware padding covers the alignment rows; \
|
||||
PUNKTFUNK_VULKAN_RGB_TRUE_EXTENT=0 restores the padded copy)",
|
||||
(_, _, Some(RgbDirect { padded: false, .. })) => "active",
|
||||
(_, _, Some(RgbDirect { padded: true, .. })) =>
|
||||
"active(padded-copy: mode is not 64x16-aligned — staging blit + edge \
|
||||
duplication instead of the direct import)",
|
||||
(Ok(_), false, None) =>
|
||||
"available(off: PUNKTFUNK_VULKAN_RGB_DIRECT=0, or a cursor-blend session \
|
||||
— =1 forces)",
|
||||
(Err(e), _, None) => e,
|
||||
(Ok(_), true, None) => unreachable!("rgb gate and cfg disagree"),
|
||||
},
|
||||
"vulkan-encode: EFC RGB-direct encode source (design/vulkan-rgb-direct-encode.md)"
|
||||
);
|
||||
}
|
||||
|
||||
// the encode profile — H265 Main, or AV1 Main; chained raw and uniformly (vendored AV1 +
|
||||
// rgb structs can't `push_next`, and the chain must match [`RgbProfileStack::wire`]'s
|
||||
@@ -820,13 +940,19 @@ impl VulkanVideoEncoder {
|
||||
|
||||
// ---- session parameters + header framing (HEVC: VPS/SPS/PPS on keyframes; AV1: a
|
||||
// temporal-delimiter OBU per frame + a sequence-header OBU on keyframes) ----
|
||||
// Native NV12 authors TRUE-SIZE headers (SPS/seq at the render size, no app-side
|
||||
// conformance window): RADV rounds the bitstream SPS up itself and, keyed off the
|
||||
// matching true-size codedExtent, programs the VCN with firmware padding so the source
|
||||
// is never read past its extent. The CSC/RGB paths keep the app-aligned convention
|
||||
// (their sources genuinely cover the aligned extent).
|
||||
let (hdr_w, hdr_h) = if native_nv12 { (rw, rh) } else { (w, h) };
|
||||
let (params, header, frame_prefix) = if av1 {
|
||||
build_parameters_av1(
|
||||
&device,
|
||||
&vq_dev,
|
||||
session,
|
||||
w,
|
||||
h,
|
||||
hdr_w,
|
||||
hdr_h,
|
||||
rw,
|
||||
rh,
|
||||
av1_caps.max_level,
|
||||
@@ -839,8 +965,8 @@ impl VulkanVideoEncoder {
|
||||
&vq_dev,
|
||||
&venc_dev,
|
||||
session,
|
||||
w,
|
||||
h,
|
||||
hdr_w,
|
||||
hdr_h,
|
||||
rw,
|
||||
rh,
|
||||
quality_level,
|
||||
@@ -996,9 +1122,14 @@ impl VulkanVideoEncoder {
|
||||
compute_pool,
|
||||
bs_size,
|
||||
sampler,
|
||||
ts_period_ns > 0.0 && rgb_cfg.is_none(),
|
||||
rgb_cfg.is_none(),
|
||||
rgb_cfg.as_ref().is_some_and(|c| c.padded),
|
||||
ts_period_ns > 0.0
|
||||
&& ((rgb_cfg.is_none() && !native_nv12)
|
||||
|| rgb_cfg.as_ref().is_some_and(|c| c.padded)),
|
||||
rgb_cfg.is_none() && !native_nv12,
|
||||
rgb_cfg
|
||||
.as_ref()
|
||||
.is_some_and(|c| c.padded)
|
||||
.then_some(vk::Format::B8G8R8A8_UNORM),
|
||||
guard.frames.last_mut().expect("frame just pushed"),
|
||||
)?;
|
||||
}
|
||||
@@ -1052,6 +1183,7 @@ impl VulkanVideoEncoder {
|
||||
ts_period_ns,
|
||||
perf_at: std::time::Instant::now(),
|
||||
rgb: rgb_cfg,
|
||||
native_nv12,
|
||||
warned_cursor: false,
|
||||
pending_bitrate: None,
|
||||
width: w,
|
||||
@@ -1200,15 +1332,31 @@ impl VulkanVideoEncoder {
|
||||
}
|
||||
}
|
||||
|
||||
/// Import a packed-RGB dmabuf as a VkImage (explicit DRM modifier). CSC sessions import it
|
||||
/// SAMPLED (the compute shader reads it); RGB-direct sessions import it as a profiled
|
||||
/// `VIDEO_ENCODE_SRC` — the buffer IS the encode source. Caller destroys.
|
||||
/// Import a DMA-BUF VkImage with usage/profile matching this session's source mode. Native
|
||||
/// NV12 and aligned RGB-direct are profiled `VIDEO_ENCODE_SRC` images. Padded RGB-direct
|
||||
/// imports the producer allocation as transfer-source only.
|
||||
unsafe fn import_dmabuf(
|
||||
&self,
|
||||
d: &pf_frame::DmabufFrame,
|
||||
cw: u32,
|
||||
ch: u32,
|
||||
) -> Result<(vk::Image, vk::DeviceMemory, vk::ImageView)> {
|
||||
if self.native_nv12 {
|
||||
let mut ps = NativeProfileStack::new(codec_op_for(self.codec == Codec::Av1));
|
||||
let profile = *ps.wire(self.codec == Codec::Av1);
|
||||
let arr = [profile];
|
||||
let mut plist = vk::VideoProfileListInfoKHR::default().profiles(&arr);
|
||||
return super::vk_util::import_rgb_dmabuf_as(
|
||||
&self.device,
|
||||
&self.ext_fd,
|
||||
&self.mem_props,
|
||||
d,
|
||||
cw,
|
||||
ch,
|
||||
vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR,
|
||||
Some(&mut plist),
|
||||
);
|
||||
}
|
||||
if self.rgb.as_ref().is_some_and(|r| r.padded) {
|
||||
// Padded-copy mode: the import is only ever a transfer SOURCE (the blit into the
|
||||
// aligned staging image) — plain TRANSFER_SRC, no video profile involved.
|
||||
@@ -1503,8 +1651,21 @@ impl VulkanVideoEncoder {
|
||||
setup_idx = (setup_idx + 1) % DPB_SLOTS as usize;
|
||||
}
|
||||
|
||||
// ---- 2..4 diverge by encode source; the RGB-direct twin returns through the shared
|
||||
// bookkeeping tail (design/vulkan-rgb-direct-encode.md B1) ----
|
||||
// ---- 2..4 diverge by encode source; native NV12 and RGB-direct return through the
|
||||
// shared bookkeeping tail ----
|
||||
if self.native_nv12 {
|
||||
self.record_submit_nv12(slot, frame, is_idr, recovery, ref_slot, setup_idx, poc)?;
|
||||
self.post_submit_bookkeeping(
|
||||
slot,
|
||||
frame.pts_ns,
|
||||
wire,
|
||||
is_idr,
|
||||
recovery,
|
||||
setup_idx,
|
||||
poc,
|
||||
);
|
||||
return Ok(());
|
||||
}
|
||||
if self.rgb.is_some() {
|
||||
self.record_submit_rgb(slot, frame, is_idr, recovery, ref_slot, setup_idx, poc)?;
|
||||
self.post_submit_bookkeeping(
|
||||
@@ -1831,12 +1992,20 @@ impl VulkanVideoEncoder {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Padded-copy blit (unaligned-mode RGB-direct): record — into `compute_cmd` — the visible
|
||||
/// frame copy from the imported capture image into the aligned staging image, plus the edge
|
||||
/// duplication into the 64x16 padding (the same edge semantics the CSC shader implements
|
||||
/// with clamped reads). Transfer-only, no shader. The staging image lives in GENERAL — it
|
||||
/// is both copy dst and, for the right-column pass, copy src — and the encode acquires it
|
||||
/// via [`SrcAcquire::CscGeneral`] (content produced on the compute queue, csc_sem-ordered).
|
||||
/// Padded-copy blit (unaligned-mode RGB-direct or native NV12): record — into `compute_cmd`
|
||||
/// — the visible frame copy from the imported capture image into the aligned staging image,
|
||||
/// plus the edge duplication into the 64x16 padding (the same edge semantics the CSC shader
|
||||
/// implements with clamped reads). Transfer-only, no shader. The staging image lives in
|
||||
/// GENERAL — it is both copy dst and, for the right-column pass, copy src — and the encode
|
||||
/// acquires it via [`SrcAcquire::CscGeneral`] (content produced on the compute queue,
|
||||
/// csc_sem-ordered).
|
||||
///
|
||||
/// `planes` lists the copy aspects with their subsampling divisor — `[(COLOR, 1)]` for
|
||||
/// packed RGB, `[(PLANE_0, 1), (PLANE_1, 2)]` for NV12 (multi-planar copy regions are in
|
||||
/// each plane's own coordinate space; barriers on non-disjoint images stay COLOR-aspect).
|
||||
/// Every divisor must divide the visible and aligned extents (4:2:0 frames are even, the
|
||||
/// coded extent is 64x16-aligned).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn record_pad_blit(
|
||||
&self,
|
||||
dev: &ash::Device,
|
||||
@@ -1844,6 +2013,8 @@ impl VulkanVideoEncoder {
|
||||
src: vk::Image,
|
||||
src_fresh: bool,
|
||||
pad: vk::Image,
|
||||
ts_pool: vk::QueryPool,
|
||||
planes: &[(vk::ImageAspectFlags, u32)],
|
||||
) -> Result<()> {
|
||||
let (rw, rh) = (self.render_w, self.render_h);
|
||||
let (w, h) = (self.width, self.height);
|
||||
@@ -1852,6 +2023,10 @@ impl VulkanVideoEncoder {
|
||||
&vk::CommandBufferBeginInfo::default()
|
||||
.flags(vk::CommandBufferUsageFlags::ONE_TIME_SUBMIT),
|
||||
)?;
|
||||
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);
|
||||
}
|
||||
// Acquire the imported capture buffer for transfer reads (FOREIGN hand-off on first
|
||||
// import — UNDEFINED preserves the modifier-tiled bytes — visibility-only afterwards),
|
||||
// and the staging image for transfer writes (prior contents discarded).
|
||||
@@ -1893,26 +2068,32 @@ impl VulkanVideoEncoder {
|
||||
compute_cmd,
|
||||
&vk::DependencyInfo::default().image_memory_barriers(&[src_acq, pad_dst]),
|
||||
);
|
||||
let layers = vk::ImageSubresourceLayers::default()
|
||||
.aspect_mask(vk::ImageAspectFlags::COLOR)
|
||||
.layer_count(1);
|
||||
let region = |sx: i32, sy: i32, dx: i32, dy: i32, cw: u32, ch: u32| {
|
||||
vk::ImageCopy::default()
|
||||
.src_subresource(layers)
|
||||
.dst_subresource(layers)
|
||||
.src_offset(vk::Offset3D { x: sx, y: sy, z: 0 })
|
||||
.dst_offset(vk::Offset3D { x: dx, y: dy, z: 0 })
|
||||
.extent(vk::Extent3D {
|
||||
width: cw,
|
||||
height: ch,
|
||||
depth: 1,
|
||||
})
|
||||
};
|
||||
// Pass 1 — from the capture: the visible region, then each bottom padding row as a
|
||||
// copy of the last visible row (1080p: 8 such rows). One call, disjoint regions.
|
||||
let mut regions = vec![region(0, 0, 0, 0, rw, rh)];
|
||||
for y in rh..h {
|
||||
regions.push(region(0, rh as i32 - 1, 0, y as i32, rw, 1));
|
||||
let region =
|
||||
|aspect: vk::ImageAspectFlags, sx: i32, sy: i32, dx: i32, dy: i32, cw: u32, ch: u32| {
|
||||
let layers = vk::ImageSubresourceLayers::default()
|
||||
.aspect_mask(aspect)
|
||||
.layer_count(1);
|
||||
vk::ImageCopy::default()
|
||||
.src_subresource(layers)
|
||||
.dst_subresource(layers)
|
||||
.src_offset(vk::Offset3D { x: sx, y: sy, z: 0 })
|
||||
.dst_offset(vk::Offset3D { x: dx, y: dy, z: 0 })
|
||||
.extent(vk::Extent3D {
|
||||
width: cw,
|
||||
height: ch,
|
||||
depth: 1,
|
||||
})
|
||||
};
|
||||
// Pass 1 — from the capture, per plane: the visible region, then each bottom padding row
|
||||
// as a copy of the last visible row (1080p: 8 luma + 4 chroma rows). One call, disjoint
|
||||
// regions.
|
||||
let mut regions = Vec::new();
|
||||
for &(aspect, div) in planes {
|
||||
let (rw, rh, h) = (rw / div, rh / div, h / div);
|
||||
regions.push(region(aspect, 0, 0, 0, 0, rw, rh));
|
||||
for y in rh..h {
|
||||
regions.push(region(aspect, 0, rh as i32 - 1, 0, y as i32, rw, 1));
|
||||
}
|
||||
}
|
||||
dev.cmd_copy_image(
|
||||
compute_cmd,
|
||||
@@ -1942,9 +2123,13 @@ impl VulkanVideoEncoder {
|
||||
compute_cmd,
|
||||
&vk::DependencyInfo::default().image_memory_barriers(&[self_dep]),
|
||||
);
|
||||
let cols: Vec<vk::ImageCopy> = (rw..w)
|
||||
.map(|x| region(rw as i32 - 1, 0, x as i32, 0, 1, h))
|
||||
.collect();
|
||||
let mut cols = Vec::new();
|
||||
for &(aspect, div) in planes {
|
||||
let (rw, w, h) = (rw / div, w / div, h / div);
|
||||
for x in rw..w {
|
||||
cols.push(region(aspect, rw as i32 - 1, 0, x as i32, 0, 1, h));
|
||||
}
|
||||
}
|
||||
dev.cmd_copy_image(
|
||||
compute_cmd,
|
||||
pad,
|
||||
@@ -1954,10 +2139,112 @@ impl VulkanVideoEncoder {
|
||||
&cols,
|
||||
);
|
||||
}
|
||||
if self.ts_period_ns > 0.0 {
|
||||
dev.cmd_write_timestamp2(
|
||||
compute_cmd,
|
||||
vk::PipelineStageFlags2::ALL_COMMANDS,
|
||||
ts_pool,
|
||||
1,
|
||||
);
|
||||
}
|
||||
dev.end_command_buffer(compute_cmd)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Producer-native NV12 submit: import the producer's visible-size buffer directly as the
|
||||
/// encode source. Safe at every mode because native sessions run true-size headers — the
|
||||
/// picture resources' codedExtent equals the source extent and the VCN firmware edge-extends
|
||||
/// the alignment padding internally (see the [`Self::native_nv12`] field docs).
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
unsafe fn record_submit_nv12(
|
||||
&mut self,
|
||||
slot: usize,
|
||||
frame: &CapturedFrame,
|
||||
is_idr: bool,
|
||||
recovery: bool,
|
||||
ref_slot: usize,
|
||||
setup_idx: usize,
|
||||
poc: i32,
|
||||
) -> Result<()> {
|
||||
if frame.format != PixelFormat::Nv12 {
|
||||
bail!(
|
||||
"vulkan-encode (native NV12): negotiated NV12 but received {:?}",
|
||||
frame.format
|
||||
);
|
||||
}
|
||||
if frame.width != self.render_w || frame.height != self.render_h {
|
||||
bail!(
|
||||
"vulkan-encode (native NV12): frame {}x{} != mode {}x{}",
|
||||
frame.width,
|
||||
frame.height,
|
||||
self.render_w,
|
||||
self.render_h
|
||||
);
|
||||
}
|
||||
if frame.width % 2 != 0 || frame.height % 2 != 0 {
|
||||
bail!("vulkan-encode (native NV12): 4:2:0 frame dimensions must be even");
|
||||
}
|
||||
let FramePayload::Dmabuf(d) = &frame.payload else {
|
||||
bail!("vulkan-encode (native NV12): producer frame is not a DMA-BUF");
|
||||
};
|
||||
if d.fourcc != pf_frame::drm_fourcc(PixelFormat::Nv12).expect("NV12 FourCC") {
|
||||
bail!(
|
||||
"vulkan-encode (native NV12): DMA-BUF FourCC {:#x} is not NV12",
|
||||
d.fourcc
|
||||
);
|
||||
}
|
||||
if d.modifier != 0 {
|
||||
bail!(
|
||||
"vulkan-encode (native NV12): only LINEAR is supported, got modifier {:#x}",
|
||||
d.modifier
|
||||
);
|
||||
}
|
||||
// No compositing stage exists here (like RGB-direct/EFC): gamescope embeds its pointer
|
||||
// in the produced pixels, but any other NV12 producer's metadata cursor would be lost —
|
||||
// say so once instead of silently.
|
||||
if frame.cursor.is_some() && !self.warned_cursor {
|
||||
self.warned_cursor = true;
|
||||
tracing::warn!(
|
||||
"cursor bitmap on a native-NV12 session — nothing composites it; the cursor \
|
||||
will be missing from the stream (unset PUNKTFUNK_PIPEWIRE_NV12 for \
|
||||
metadata-cursor captures)"
|
||||
);
|
||||
}
|
||||
let dev = self.device.clone();
|
||||
let cmd = self.frames[slot].cmd;
|
||||
let fence = self.frames[slot].fence;
|
||||
let query_pool = self.frames[slot].query_pool;
|
||||
let bs_buf = self.frames[slot].bs_buf;
|
||||
// The frame-size check above proved the buffer covers the (true-size) coded extent —
|
||||
// the direct import is the encode source at every mode.
|
||||
let (src_img, src_view, fresh) = self.import_cached(d, frame.width, frame.height)?;
|
||||
let acquire = if fresh {
|
||||
SrcAcquire::DmabufFresh
|
||||
} else {
|
||||
SrcAcquire::DmabufCached
|
||||
};
|
||||
if self.codec == Codec::Av1 {
|
||||
self.record_coding_av1(
|
||||
&dev, cmd, query_pool, bs_buf, src_img, src_view, acquire, is_idr, recovery,
|
||||
ref_slot, setup_idx, poc,
|
||||
)?;
|
||||
} else {
|
||||
self.record_coding_h265(
|
||||
&dev, cmd, query_pool, bs_buf, src_img, src_view, acquire, is_idr, ref_slot,
|
||||
setup_idx, poc,
|
||||
)?;
|
||||
}
|
||||
dev.reset_fences(&[fence])?;
|
||||
// The whole frame is one submit: the encoder reads the imported NV12 directly.
|
||||
let ecmds = [cmd];
|
||||
dev.queue_submit(
|
||||
self.encode_queue,
|
||||
&[vk::SubmitInfo::default().command_buffers(&ecmds)],
|
||||
fence,
|
||||
)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// RGB-direct twin of [`record_submit`]'s steps 2–4 (step 1 and the bookkeeping tail are
|
||||
/// shared): resolve the RGB encode source — the imported capture dmabuf itself, or the CPU
|
||||
/// staging upload — record the encode, and submit. There is no compute CSC: the VCN EFC
|
||||
@@ -1981,6 +2268,7 @@ impl VulkanVideoEncoder {
|
||||
let fence = self.frames[slot].fence;
|
||||
let query_pool = self.frames[slot].query_pool;
|
||||
let bs_buf = self.frames[slot].bs_buf;
|
||||
let ts_pool = self.frames[slot].ts_pool;
|
||||
// EFC cannot composite the cursor bitmap the metadata-cursor captures hand us — say so
|
||||
// once instead of silently losing the pointer (gamescope, the flagship, embeds it).
|
||||
if frame.cursor.is_some() && !self.warned_cursor {
|
||||
@@ -1995,25 +2283,33 @@ impl VulkanVideoEncoder {
|
||||
let (src_img, src_view, acquire, compute_active) = match &frame.payload {
|
||||
FramePayload::Dmabuf(d) if !padded => {
|
||||
// Defense in depth for the OOB class the alignment gate closes at open: the
|
||||
// imported buffer must cover the FULL coded extent, or the EFC reads past it
|
||||
// (VM faults → VCN ring hang → GPU reset, the 2026-07-20 field report). A
|
||||
// mismatched frame (mid-flight mode change, odd capture) errors out here and
|
||||
// takes the encoder-rebuild path instead of faulting the GPU.
|
||||
if frame.width != self.width || frame.height != self.height {
|
||||
// imported buffer must cover the source extent the encode declares — the FULL
|
||||
// aligned coded extent normally (or the EFC reads past it: VM faults → VCN
|
||||
// ring hang → GPU reset, the 2026-07-20 field report), the render size in
|
||||
// true-extent mode (where the declared source codedExtent shrinks with it and
|
||||
// RADV's firmware padding covers the alignment rows). A mismatched frame
|
||||
// (mid-flight mode change, odd capture) errors out here and takes the
|
||||
// encoder-rebuild path instead of faulting the GPU.
|
||||
let (need_w, need_h) = if self.rgb.as_ref().is_some_and(|r| r.true_extent) {
|
||||
(self.render_w, self.render_h)
|
||||
} else {
|
||||
(self.width, self.height)
|
||||
};
|
||||
if frame.width != need_w || frame.height != need_h {
|
||||
bail!(
|
||||
"vulkan-encode (rgb-direct): frame {}x{} does not cover the coded \
|
||||
extent {}x{} — refusing an out-of-bounds encode source",
|
||||
"vulkan-encode (rgb-direct): frame {}x{} does not cover the declared \
|
||||
source extent {}x{} — refusing an out-of-bounds encode source",
|
||||
frame.width,
|
||||
frame.height,
|
||||
self.width,
|
||||
self.height
|
||||
need_w,
|
||||
need_h
|
||||
);
|
||||
}
|
||||
let (img, view, fresh) = self.import_cached(d, frame.width, frame.height)?;
|
||||
let acq = if fresh {
|
||||
SrcAcquire::RgbFresh
|
||||
SrcAcquire::DmabufFresh
|
||||
} else {
|
||||
SrcAcquire::RgbCached
|
||||
SrcAcquire::DmabufCached
|
||||
};
|
||||
(img, view, acq, false)
|
||||
}
|
||||
@@ -2036,7 +2332,15 @@ impl VulkanVideoEncoder {
|
||||
let (img, _view, fresh) = self.import_cached(d, frame.width, frame.height)?;
|
||||
let pad_img = self.frames[slot].pad_img;
|
||||
let pad_view = self.frames[slot].pad_view;
|
||||
self.record_pad_blit(&dev, compute_cmd, img, fresh, pad_img)?;
|
||||
self.record_pad_blit(
|
||||
&dev,
|
||||
compute_cmd,
|
||||
img,
|
||||
fresh,
|
||||
pad_img,
|
||||
ts_pool,
|
||||
&[(vk::ImageAspectFlags::COLOR, 1)],
|
||||
)?;
|
||||
// The staging image ends the blit in GENERAL with the csc_sem ordering the
|
||||
// hand-off — exactly the CscGeneral acquire contract.
|
||||
(pad_img, pad_view, SrcAcquire::CscGeneral, true)
|
||||
@@ -2240,13 +2544,13 @@ impl VulkanVideoEncoder {
|
||||
.src_stage_mask(vk::PipelineStageFlags2::ALL_COMMANDS)
|
||||
.src_access_mask(vk::AccessFlags2::NONE)
|
||||
.old_layout(vk::ImageLayout::GENERAL),
|
||||
SrcAcquire::RgbFresh => src_base
|
||||
SrcAcquire::DmabufFresh => src_base
|
||||
.src_stage_mask(vk::PipelineStageFlags2::NONE)
|
||||
.src_access_mask(vk::AccessFlags2::NONE)
|
||||
.old_layout(vk::ImageLayout::UNDEFINED)
|
||||
.src_queue_family_index(vk::QUEUE_FAMILY_FOREIGN_EXT)
|
||||
.dst_queue_family_index(self.encode_family),
|
||||
SrcAcquire::RgbCached => src_base
|
||||
SrcAcquire::DmabufCached => src_base
|
||||
.src_stage_mask(vk::PipelineStageFlags2::NONE)
|
||||
.src_access_mask(vk::AccessFlags2::NONE)
|
||||
.old_layout(vk::ImageLayout::VIDEO_ENCODE_SRC_KHR),
|
||||
@@ -2284,9 +2588,29 @@ impl VulkanVideoEncoder {
|
||||
poc: i32,
|
||||
) -> Result<()> {
|
||||
use ash::vk::native as h;
|
||||
let ext2d = vk::Extent2D {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
// Setup/reference extent: the aligned size for app-aligned sessions (CSC, RGB — it
|
||||
// pairs with their aligned SPS), the render size for native NV12's true-size headers.
|
||||
let ext2d = if self.native_nv12 {
|
||||
vk::Extent2D {
|
||||
width: self.render_w,
|
||||
height: self.render_h,
|
||||
}
|
||||
} else {
|
||||
vk::Extent2D {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
};
|
||||
// Source extent additionally drops to the render size in RGB true-extent mode: RADV
|
||||
// derives the VCN firmware padding from srcPictureResource.codedExtent (Mesa ≥ 24.2),
|
||||
// so the visible-size import is never read past its extent (see RgbDirect::true_extent).
|
||||
let src_extent = if self.rgb.as_ref().is_some_and(|r| r.true_extent) {
|
||||
vk::Extent2D {
|
||||
width: self.render_w,
|
||||
height: self.render_h,
|
||||
}
|
||||
} else {
|
||||
ext2d
|
||||
};
|
||||
let ref_poc = if is_idr { 0 } else { self.slot_poc[ref_slot] };
|
||||
|
||||
@@ -2458,7 +2782,7 @@ impl VulkanVideoEncoder {
|
||||
}
|
||||
dev.cmd_begin_query(cmd, query_pool, 0, vk::QueryControlFlags::empty());
|
||||
let src_res = vk::VideoPictureResourceInfoKHR::default()
|
||||
.coded_extent(ext2d)
|
||||
.coded_extent(src_extent)
|
||||
.image_view_binding(src_view);
|
||||
let mut enc = vk::VideoEncodeInfoKHR::default()
|
||||
.dst_buffer(bs_buf)
|
||||
@@ -2502,9 +2826,29 @@ impl VulkanVideoEncoder {
|
||||
) -> Result<()> {
|
||||
use super::vk_av1_encode as av1;
|
||||
use ash::vk::native as h;
|
||||
let ext2d = vk::Extent2D {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
// Setup/reference extent: the aligned size for app-aligned sessions (CSC, RGB — it
|
||||
// pairs with their aligned SPS), the render size for native NV12's true-size headers.
|
||||
let ext2d = if self.native_nv12 {
|
||||
vk::Extent2D {
|
||||
width: self.render_w,
|
||||
height: self.render_h,
|
||||
}
|
||||
} else {
|
||||
vk::Extent2D {
|
||||
width: self.width,
|
||||
height: self.height,
|
||||
}
|
||||
};
|
||||
// Source extent additionally drops to the render size in RGB true-extent mode: RADV
|
||||
// derives the VCN firmware padding from srcPictureResource.codedExtent (Mesa ≥ 24.2),
|
||||
// so the visible-size import is never read past its extent (see RgbDirect::true_extent).
|
||||
let src_extent = if self.rgb.as_ref().is_some_and(|r| r.true_extent) {
|
||||
vk::Extent2D {
|
||||
width: self.render_w,
|
||||
height: self.render_h,
|
||||
}
|
||||
} else {
|
||||
ext2d
|
||||
};
|
||||
|
||||
// ---- required AV1 frame sub-structs (single tile; no CDEF/LR/segmentation/global-motion) ----
|
||||
@@ -2726,7 +3070,7 @@ impl VulkanVideoEncoder {
|
||||
}
|
||||
dev.cmd_begin_query(cmd, query_pool, 0, vk::QueryControlFlags::empty());
|
||||
let src_res = vk::VideoPictureResourceInfoKHR::default()
|
||||
.coded_extent(ext2d)
|
||||
.coded_extent(src_extent)
|
||||
.image_view_binding(src_view);
|
||||
let mut enc = vk::VideoEncodeInfoKHR::default()
|
||||
.dst_buffer(bs_buf)
|
||||
@@ -2784,10 +3128,13 @@ impl VulkanVideoEncoder {
|
||||
);
|
||||
}
|
||||
let (off, len) = (off64 as usize, len64 as usize);
|
||||
// PUNKTFUNK_PERF CSC split (best-effort): the fence signaled, so the compute batch that
|
||||
// wrote these timestamps completed long ago — WAIT is a formality. Sampled to one log
|
||||
// line per ~2 s; `wait_us` in the pump's stage perf minus this ≈ the ASIC encode.
|
||||
if self.ts_period_ns > 0.0 && f.ts_pool != vk::QueryPool::null() {
|
||||
// PUNKTFUNK_PERF pre-encode split (best-effort): the fence signaled, so the compute/transfer
|
||||
// timestamps are available. This is a device duration, not permission to label the
|
||||
// remaining host fence wait as pure VCN time; queueing and synchronization remain in it.
|
||||
if self.ts_period_ns > 0.0
|
||||
&& f.ts_pool != vk::QueryPool::null()
|
||||
&& self.perf_at.elapsed() >= std::time::Duration::from_secs(2)
|
||||
{
|
||||
let mut ts = [0u64; 2];
|
||||
if dev
|
||||
.get_query_pool_results(
|
||||
@@ -2797,16 +3144,26 @@ impl VulkanVideoEncoder {
|
||||
vk::QueryResultFlags::TYPE_64 | vk::QueryResultFlags::WAIT,
|
||||
)
|
||||
.is_ok()
|
||||
&& self.perf_at.elapsed() >= std::time::Duration::from_secs(2)
|
||||
{
|
||||
self.perf_at = std::time::Instant::now();
|
||||
let csc_us = (ts[1].saturating_sub(ts[0]) as f64 * self.ts_period_ns) / 1000.0;
|
||||
tracing::info!(
|
||||
csc_us = format!("{csc_us:.0}"),
|
||||
au_bytes = len,
|
||||
"vulkan-encode split (sampled): csc=GPU compute batch (import barriers + \
|
||||
CSC + plane copies); ASIC encode ≈ stage-perf wait_us − csc"
|
||||
);
|
||||
let pre_encode_us =
|
||||
(ts[1].saturating_sub(ts[0]) as f64 * self.ts_period_ns) / 1000.0;
|
||||
if self.rgb.as_ref().is_some_and(|r| r.padded) {
|
||||
tracing::info!(
|
||||
rgb_copy_us = format!("{pre_encode_us:.0}"),
|
||||
au_bytes = len,
|
||||
"vulkan-encode split (sampled): padded RGB copy device time before EFC; \
|
||||
remaining fence wait still includes queue synchronization + RGB→YUV EFC \
|
||||
+ video encode"
|
||||
);
|
||||
} else {
|
||||
tracing::info!(
|
||||
csc_us = format!("{pre_encode_us:.0}"),
|
||||
au_bytes = len,
|
||||
"vulkan-encode split (sampled): RGB→NV12 compute batch device time; \
|
||||
remaining fence wait still includes queue synchronization + video encode"
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
let f = &self.frames[slot];
|
||||
@@ -3412,26 +3769,30 @@ unsafe fn make_frame(
|
||||
sampler: vk::Sampler,
|
||||
with_ts: bool,
|
||||
csc: bool,
|
||||
rgb_pad: bool,
|
||||
pad_fmt: Option<vk::Format>,
|
||||
f: &mut Frame,
|
||||
) -> Result<()> {
|
||||
// "no cursor uploaded yet" sentinel — a real serial may be 0 (see `prep_cursor`).
|
||||
f.cursor_serial = u64::MAX;
|
||||
// Padded-copy RGB staging (unaligned-mode RGB-direct): aligned BGRA encode-src filled by a
|
||||
// transfer blit each frame — concurrent compute (copy) + encode (source read).
|
||||
if rgb_pad {
|
||||
// Padded-copy staging (unaligned-mode RGB-direct or native NV12): an aligned encode-src in
|
||||
// the session's picture format, filled by a transfer blit each frame — concurrent compute
|
||||
// (copy) + encode (source read). TRANSFER_SRC because the width-padding pass self-copies the
|
||||
// staging image's own last visible column (see `record_pad_blit`).
|
||||
if let Some(fmt) = pad_fmt {
|
||||
(f.pad_img, f.pad_mem) = make_video_image(
|
||||
device,
|
||||
mem_props,
|
||||
vk::Format::B8G8R8A8_UNORM,
|
||||
fmt,
|
||||
w,
|
||||
h,
|
||||
1,
|
||||
vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR | vk::ImageUsageFlags::TRANSFER_DST,
|
||||
vk::ImageUsageFlags::VIDEO_ENCODE_SRC_KHR
|
||||
| vk::ImageUsageFlags::TRANSFER_DST
|
||||
| vk::ImageUsageFlags::TRANSFER_SRC,
|
||||
profile_list,
|
||||
fams,
|
||||
)?;
|
||||
f.pad_view = make_view(device, f.pad_img, vk::Format::B8G8R8A8_UNORM, 0)?;
|
||||
f.pad_view = make_view(device, f.pad_img, fmt, 0)?;
|
||||
}
|
||||
// RGB-direct sessions never touch the CSC pipeline: no NV12 encode-src, no Y/UV scratch, no
|
||||
// cursor overlay, no descriptor set — the encode source is the imported RGB itself (or the
|
||||
|
||||
@@ -478,10 +478,14 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
|
||||
b
|
||||
});
|
||||
|
||||
// HDR signalling (10-bit sessions are the HDR path on Windows — same coupling as NVENC):
|
||||
// BT.2020/PQ colour description + the source's mastering/CLL grade at every IDR.
|
||||
// Colour signalling, written UNCONDITIONALLY (mirrors nvenc_core.rs): the input is already
|
||||
// CSC'd to a specific matrix — BT.709 limited for SDR (the capture-side VideoConverter),
|
||||
// BT.2020 PQ for HDR (HdrP010Converter) — so the stream must say so. An SDR stream without a
|
||||
// colour description leaves the choice to the decoder's "unspecified" default, and
|
||||
// Moonlight/third-party/Android-vendor decoders default to 601 at sub-HD → mis-rendered
|
||||
// colours. (10-bit sessions are the HDR path on Windows — same coupling as NVENC.)
|
||||
let hdr = cfg.ten_bit && cfg.codec != Codec::H264;
|
||||
let vsi = hdr.then(|| {
|
||||
let vsi = {
|
||||
// SAFETY: all-zero is valid; header stamped below.
|
||||
let mut b: Box<vpl::mfxExtVideoSignalInfo> = Box::new(unsafe { std::mem::zeroed() });
|
||||
b.Header.BufferId = vpl::MFX_EXTBUFF_VIDEO_SIGNAL_INFO as u32;
|
||||
@@ -489,11 +493,17 @@ fn build_params(cfg: &EncodeConfig) -> ParamSet {
|
||||
b.VideoFormat = 5; // unspecified
|
||||
b.VideoFullRange = 0;
|
||||
b.ColourDescriptionPresent = 1;
|
||||
b.ColourPrimaries = 9; // BT.2020
|
||||
b.TransferCharacteristics = 16; // SMPTE ST 2084 (PQ)
|
||||
b.MatrixCoefficients = 9; // BT.2020 non-constant
|
||||
b
|
||||
});
|
||||
if hdr {
|
||||
b.ColourPrimaries = 9; // BT.2020
|
||||
b.TransferCharacteristics = 16; // SMPTE ST 2084 (PQ)
|
||||
b.MatrixCoefficients = 9; // BT.2020 non-constant
|
||||
} else {
|
||||
b.ColourPrimaries = 1; // BT.709
|
||||
b.TransferCharacteristics = 1; // BT.709
|
||||
b.MatrixCoefficients = 1; // BT.709
|
||||
}
|
||||
Some(b)
|
||||
};
|
||||
let mastering = cfg.hdr_meta.filter(|_| hdr).map(|m| {
|
||||
// SAFETY: all-zero is valid; header stamped below.
|
||||
let mut b: Box<vpl::mfxExtMasteringDisplayColourVolume> =
|
||||
@@ -1994,4 +2004,246 @@ mod tests {
|
||||
"the bitrate retarget emitted a keyframe (StartNewSequence leak)"
|
||||
);
|
||||
}
|
||||
|
||||
/// FULL-CHAIN colour check at the field capture size: a known P010 colour-bar source at
|
||||
/// 1920x1080 — whose height is NOT 16-aligned, so the ingest `CopySubresourceRegion` copies
|
||||
/// into a 1920x1088 runtime pool surface whose chroma plane sits at a DIFFERENT row offset
|
||||
/// than the source's (the seam no 640x480 test exercises) — encoded to Main10 HEVC and
|
||||
/// dumped to `%TEMP%\pf_qsv_1080_bars.h265` for off-box decode verification against the
|
||||
/// same codes. On-box this asserts stream shape; the pixel verdict needs a decoder.
|
||||
#[test]
|
||||
fn qsv_live_p010_1080_colorbars_dump() {
|
||||
use windows::Win32::Graphics::Direct3D::D3D_DRIVER_TYPE_UNKNOWN;
|
||||
use windows::Win32::Graphics::Direct3D11::{
|
||||
D3D11CreateDevice, D3D11_BIND_RENDER_TARGET, D3D11_BIND_SHADER_RESOURCE,
|
||||
D3D11_SDK_VERSION, D3D11_SUBRESOURCE_DATA, D3D11_USAGE_DEFAULT,
|
||||
};
|
||||
use windows::Win32::Graphics::Dxgi::Common::{DXGI_FORMAT_P010, DXGI_SAMPLE_DESC};
|
||||
use windows::Win32::Graphics::Dxgi::{CreateDXGIFactory1, IDXGIAdapter1, IDXGIFactory4};
|
||||
|
||||
// (Y, Cb, Cr) 10-bit limited codes for the 8 sRGB bars white/yellow/cyan/green/magenta/
|
||||
// red/blue/black at 80-nit SDR white under PQ/BT.2020 — the same math as pf-capture's
|
||||
// `p010_reference` (and the bars_pq2020 client fixture). Stored MSB-aligned (`<<6`).
|
||||
const BARS: [(u16, u16, u16); 8] = [
|
||||
(490, 512, 512),
|
||||
(478, 423, 518),
|
||||
(464, 525, 473),
|
||||
(450, 432, 476),
|
||||
(350, 584, 585),
|
||||
(325, 448, 598),
|
||||
(226, 650, 535),
|
||||
(64, 512, 512),
|
||||
];
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
|
||||
init_tracing();
|
||||
let Ok((_l, impls)) = intel_loader() else {
|
||||
eprintln!("skipping: no VPL loader");
|
||||
return;
|
||||
};
|
||||
let Some(imp) = impls.iter().find(|i| i.luid_valid) else {
|
||||
eprintln!("skipping: no Intel VPL implementation on this box");
|
||||
return;
|
||||
};
|
||||
if !probe_can_encode_10bit(Codec::H265) {
|
||||
eprintln!("skipping: this GPU declines 10-bit HEVC");
|
||||
return;
|
||||
}
|
||||
|
||||
// P010 initial data: plane 0 = H rows of W u16 luma; plane 1 = H/2 rows of W u16
|
||||
// (interleaved Cb,Cr pairs), same pitch. Bars are vertical: bar index = x / (W/8).
|
||||
let bar_w = (W / 8) as usize;
|
||||
let mut init = vec![0u16; (W as usize) * (H as usize + H as usize / 2)];
|
||||
for y in 0..H as usize {
|
||||
for x in 0..W as usize {
|
||||
init[y * W as usize + x] = BARS[(x / bar_w).min(7)].0 << 6;
|
||||
}
|
||||
}
|
||||
let chroma_base = (W as usize) * (H as usize);
|
||||
for cy in 0..(H as usize / 2) {
|
||||
for cx in 0..(W as usize / 2) {
|
||||
let (_, cb, cr) = BARS[((cx * 2) / bar_w).min(7)];
|
||||
init[chroma_base + cy * W as usize + cx * 2] = cb << 6;
|
||||
init[chroma_base + cy * W as usize + cx * 2 + 1] = cr << 6;
|
||||
}
|
||||
}
|
||||
|
||||
// SAFETY: self-contained harness on one thread/device (same contract as `drive_live`);
|
||||
// the initial-data pointer outlives the synchronous CreateTexture2D that reads it.
|
||||
let (device, tex) = unsafe {
|
||||
let luid = windows::Win32::Foundation::LUID {
|
||||
LowPart: u32::from_le_bytes(imp.luid[..4].try_into().unwrap()),
|
||||
HighPart: i32::from_le_bytes(imp.luid[4..].try_into().unwrap()),
|
||||
};
|
||||
let factory: IDXGIFactory4 = CreateDXGIFactory1().expect("dxgi factory");
|
||||
let adapter: IDXGIAdapter1 = factory.EnumAdapterByLuid(luid).expect("intel adapter");
|
||||
let mut device = None;
|
||||
D3D11CreateDevice(
|
||||
&adapter,
|
||||
D3D_DRIVER_TYPE_UNKNOWN,
|
||||
windows::Win32::Foundation::HMODULE::default(),
|
||||
Default::default(),
|
||||
None,
|
||||
D3D11_SDK_VERSION,
|
||||
Some(&mut device),
|
||||
None,
|
||||
None,
|
||||
)
|
||||
.expect("d3d11 device on intel adapter");
|
||||
let device: ID3D11Device = device.expect("device");
|
||||
let desc = D3D11_TEXTURE2D_DESC {
|
||||
Width: W,
|
||||
Height: H,
|
||||
MipLevels: 1,
|
||||
ArraySize: 1,
|
||||
Format: DXGI_FORMAT_P010,
|
||||
SampleDesc: DXGI_SAMPLE_DESC {
|
||||
Count: 1,
|
||||
Quality: 0,
|
||||
},
|
||||
Usage: D3D11_USAGE_DEFAULT,
|
||||
BindFlags: (D3D11_BIND_RENDER_TARGET.0 | D3D11_BIND_SHADER_RESOURCE.0) as u32,
|
||||
CPUAccessFlags: 0,
|
||||
MiscFlags: 0,
|
||||
};
|
||||
let data = D3D11_SUBRESOURCE_DATA {
|
||||
pSysMem: init.as_ptr() as *const std::ffi::c_void,
|
||||
SysMemPitch: W * 2,
|
||||
SysMemSlicePitch: 0,
|
||||
};
|
||||
let mut t: Option<ID3D11Texture2D> = None;
|
||||
device
|
||||
.CreateTexture2D(&desc, Some(&data), Some(&mut t))
|
||||
.expect("bar texture");
|
||||
(device.clone(), t.expect("texture"))
|
||||
};
|
||||
|
||||
let mut enc = QsvEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::P010,
|
||||
W,
|
||||
H,
|
||||
30,
|
||||
10_000_000,
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open");
|
||||
enc.set_hdr_meta(Some(test_hdr_meta()));
|
||||
let mut stream = Vec::new();
|
||||
let mut aus = 0usize;
|
||||
let mut keyframes = 0usize;
|
||||
for i in 0..12u32 {
|
||||
let frame = CapturedFrame {
|
||||
width: W,
|
||||
height: H,
|
||||
pts_ns: i as u64 * 33_333_333,
|
||||
format: PixelFormat::P010,
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
enc.submit_indexed(&frame, i).expect("submit");
|
||||
if let Some(au) = enc.poll().expect("poll") {
|
||||
aus += 1;
|
||||
keyframes += au.keyframe as usize;
|
||||
stream.extend_from_slice(&au.data);
|
||||
}
|
||||
}
|
||||
enc.flush().expect("flush");
|
||||
while let Some(au) = enc.poll().expect("drain") {
|
||||
aus += 1;
|
||||
keyframes += au.keyframe as usize;
|
||||
stream.extend_from_slice(&au.data);
|
||||
}
|
||||
assert!(aus >= 10, "expected ≥10 AUs, got {aus}");
|
||||
assert!(keyframes >= 1, "expected an IDR in the dump");
|
||||
let path = std::env::temp_dir().join("pf_qsv_1080_bars.h265");
|
||||
std::fs::write(&path, &stream).expect("write dump");
|
||||
println!(
|
||||
"wrote {} AUs ({} bytes, {keyframes} keyframes) to {}",
|
||||
aus,
|
||||
stream.len(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
|
||||
/// The PRODUCTION host chain minus the IDD ring: the REAL `HdrP010Converter` renders the 8
|
||||
/// sRGB bars into a ring-profile P010 texture (`BIND_RENDER_TARGET` only — RTV-written, not
|
||||
/// CPU-uploaded) on the VPL implementation's own adapter, and THAT texture goes through the
|
||||
/// unaligned-height ingest copy into a Main10 encode. Dumped to
|
||||
/// `%TEMP%\pf_qsv_conv_1080_bars.h265`; expected decode codes = the bars_pq2020 fixture set
|
||||
/// (see `hdr_p010_convert_bars_on_luid`).
|
||||
#[test]
|
||||
fn qsv_live_hdr_converter_e2e_1080_dump() {
|
||||
const W: u32 = 1920;
|
||||
const H: u32 = 1080;
|
||||
|
||||
init_tracing();
|
||||
let Ok((_l, impls)) = intel_loader() else {
|
||||
eprintln!("skipping: no VPL loader");
|
||||
return;
|
||||
};
|
||||
let Some(imp) = impls.iter().find(|i| i.luid_valid) else {
|
||||
eprintln!("skipping: no Intel VPL implementation on this box");
|
||||
return;
|
||||
};
|
||||
if !probe_can_encode_10bit(Codec::H265) {
|
||||
eprintln!("skipping: this GPU declines 10-bit HEVC");
|
||||
return;
|
||||
}
|
||||
let (device, tex) = pf_capture::dxgi::hdr_p010_convert_bars_on_luid(imp.luid, W, H)
|
||||
.expect("converter bars");
|
||||
|
||||
let mut enc = QsvEncoder::open(
|
||||
Codec::H265,
|
||||
PixelFormat::P010,
|
||||
W,
|
||||
H,
|
||||
30,
|
||||
10_000_000,
|
||||
10,
|
||||
ChromaFormat::Yuv420,
|
||||
)
|
||||
.expect("open");
|
||||
enc.set_hdr_meta(Some(test_hdr_meta()));
|
||||
let mut stream = Vec::new();
|
||||
let mut aus = 0usize;
|
||||
for i in 0..12u32 {
|
||||
let frame = CapturedFrame {
|
||||
width: W,
|
||||
height: H,
|
||||
pts_ns: i as u64 * 33_333_333,
|
||||
format: PixelFormat::P010,
|
||||
payload: FramePayload::D3d11(pf_frame::dxgi::D3d11Frame {
|
||||
texture: tex.clone(),
|
||||
device: device.clone(),
|
||||
pyro: None,
|
||||
}),
|
||||
cursor: None,
|
||||
};
|
||||
enc.submit_indexed(&frame, i).expect("submit");
|
||||
if let Some(au) = enc.poll().expect("poll") {
|
||||
aus += 1;
|
||||
stream.extend_from_slice(&au.data);
|
||||
}
|
||||
}
|
||||
enc.flush().expect("flush");
|
||||
while let Some(au) = enc.poll().expect("drain") {
|
||||
aus += 1;
|
||||
stream.extend_from_slice(&au.data);
|
||||
}
|
||||
assert!(aus >= 10, "expected ≥10 AUs, got {aus}");
|
||||
let path = std::env::temp_dir().join("pf_qsv_conv_1080_bars.h265");
|
||||
std::fs::write(&path, &stream).expect("write dump");
|
||||
println!(
|
||||
"wrote {aus} AUs ({} bytes) to {}",
|
||||
stream.len(),
|
||||
path.display()
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -330,6 +330,7 @@ fn open_video_backend(
|
||||
{
|
||||
match vulkan_video::VulkanVideoEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
@@ -344,12 +345,32 @@ fn open_video_backend(
|
||||
);
|
||||
return Ok((Box::new(e) as Box<dyn Encoder>, "vulkan"));
|
||||
}
|
||||
// Native NV12 (PUNKTFUNK_PIPEWIRE_NV12 capture) has no VAAPI fallback:
|
||||
// libav's dmabuf lane would import the two-plane buffer as packed RGB
|
||||
// (silent garbage) and its CPU lane bails per frame — die crisply instead.
|
||||
Err(e) if format == PixelFormat::Nv12 => {
|
||||
return Err(e.context(
|
||||
"Vulkan Video open failed on a native-NV12 capture \
|
||||
— no VAAPI fallback exists; set PUNKTFUNK_PIPEWIRE_NV12=0 to \
|
||||
restore the packed-RGB negotiation",
|
||||
));
|
||||
}
|
||||
Err(e) => tracing::warn!(
|
||||
error = %format!("{e:#}"),
|
||||
"Vulkan Video encode open failed — falling back to libav VAAPI"
|
||||
),
|
||||
}
|
||||
}
|
||||
// Same rule when the Vulkan backend was never eligible (H264 session,
|
||||
// PUNKTFUNK_VULKAN_ENCODE=0, or a build without the feature).
|
||||
if format == PixelFormat::Nv12 {
|
||||
anyhow::bail!(
|
||||
"native NV12 capture requires the Vulkan Video encoder (HEVC/AV1 \
|
||||
session, --features vulkan-encode, PUNKTFUNK_VULKAN_ENCODE not 0) — this \
|
||||
session resolved to libav VAAPI; set PUNKTFUNK_PIPEWIRE_NV12=0 to restore \
|
||||
the packed-RGB negotiation"
|
||||
);
|
||||
}
|
||||
vaapi::VaapiEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
@@ -390,6 +411,7 @@ fn open_video_backend(
|
||||
}
|
||||
vulkan_video::VulkanVideoEncoder::open(
|
||||
codec,
|
||||
format,
|
||||
width,
|
||||
height,
|
||||
fps,
|
||||
@@ -819,6 +841,33 @@ fn vulkan_encode_enabled() -> bool {
|
||||
.unwrap_or(true)
|
||||
}
|
||||
|
||||
/// Whether THIS session's encoder can ingest a producer-native NV12 capture: only the raw
|
||||
/// Vulkan Video backend does (libav VAAPI would misread the two-plane buffer as packed RGB —
|
||||
/// [`open_video`] refuses the combination), so the session's codec must be one it encodes and
|
||||
/// the backend must be eligible to open. The host facade threads the verdict into the capture
|
||||
/// negotiation (`OutputFormat::nv12_native` → `ZeroCopyPolicy::native_nv12_session`), which
|
||||
/// then PREFERS gamescope's producer-side NV12 pod (default-on; `PUNKTFUNK_PIPEWIRE_NV12=0`
|
||||
/// escapes at the capture gate).
|
||||
#[cfg(target_os = "linux")]
|
||||
pub fn linux_native_nv12_ok(codec: Codec) -> bool {
|
||||
#[cfg(feature = "vulkan-encode")]
|
||||
{
|
||||
matches!(codec, Codec::H265 | Codec::Av1)
|
||||
&& vulkan_encode_enabled()
|
||||
// NVENC/PyroWave prefs never open the Vulkan Video backend; every other pref
|
||||
// (auto/vaapi/amd/intel/vulkan) tries it first on AMD/Intel — see [`open_video`].
|
||||
&& !matches!(
|
||||
pf_host_config::config().encoder_pref.as_str(),
|
||||
"nvenc" | "nvidia" | "cuda" | "pyrowave"
|
||||
)
|
||||
}
|
||||
#[cfg(not(feature = "vulkan-encode"))]
|
||||
{
|
||||
let _ = codec;
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/// Cheap, side-effect-free NVIDIA-presence probe for the `auto` backend selector: the NVIDIA
|
||||
/// kernel driver exposes these device nodes, AMD/Intel boxes have neither. Deliberately does NOT
|
||||
/// create a CUDA context (that would allocate GPU state on every host that merely *might* be
|
||||
|
||||
+31
-10
@@ -105,13 +105,15 @@ pub fn drm_fourcc(format: PixelFormat) -> Option<u32> {
|
||||
Bgra => drm_fourcc_code(b"AR24"), // DRM_FORMAT_ARGB8888
|
||||
Rgbx => drm_fourcc_code(b"XB24"), // DRM_FORMAT_XBGR8888
|
||||
Rgba => drm_fourcc_code(b"AB24"), // DRM_FORMAT_ABGR8888
|
||||
// Linux native NV12 capture (gamescope PipeWire): one LINEAR dmabuf with contiguous Y then
|
||||
// interleaved UV, exposed under DRM_FORMAT_NV12.
|
||||
Nv12 => drm_fourcc_code(b"NV12"),
|
||||
// The GNOME 50+ HDR screencast formats (packed 2:10:10:10, PQ/BT.2020).
|
||||
X2Rgb10 => drm_fourcc_code(b"XR30"), // DRM_FORMAT_XRGB2101010
|
||||
X2Bgr10 => drm_fourcc_code(b"XB30"), // DRM_FORMAT_XBGR2101010
|
||||
// 24-bit packed RGB/BGR have no straightforward dmabuf import here; use the CPU path.
|
||||
// Rgb10a2/Nv12/P010 are the Windows HDR / video-processor formats — never produced on
|
||||
// Linux; Yuv444 is OUR convert's OUTPUT, never a capture source format.
|
||||
Rgb | Bgr | Rgb10a2 | Nv12 | P010 | Yuv444 => return None,
|
||||
// Rgb10a2/P010 are Windows formats; Yuv444 is OUR convert output, never a capture source.
|
||||
Rgb | Bgr | Rgb10a2 | P010 | Yuv444 => return None,
|
||||
})
|
||||
}
|
||||
|
||||
@@ -144,6 +146,12 @@ pub struct OutputFormat {
|
||||
/// (never BGRA-passthrough / P010). `false` on every non-PyroWave session and on Linux (the
|
||||
/// wavelet encoder ingests dmabufs / CPU RGB there, not a D3D11 texture).
|
||||
pub pyrowave: bool,
|
||||
/// THIS session's encoder can ingest a producer-native NV12 capture (Linux raw Vulkan Video
|
||||
/// backend on an H265/AV1 session — see `pf_encode::linux_native_nv12_ok`). The Linux capture
|
||||
/// negotiation only offers gamescope the NV12 pod when this is set: libav VAAPI (the H264
|
||||
/// codec's backend, and the fallback family) would misread the two-plane buffer as packed
|
||||
/// RGB. Always `false` on Windows (the IDD-push capturer owns its own formats).
|
||||
pub nv12_native: bool,
|
||||
}
|
||||
|
||||
impl OutputFormat {
|
||||
@@ -161,6 +169,11 @@ impl OutputFormat {
|
||||
chroma_444: false,
|
||||
// GameStream never negotiates PyroWave (native punktfunk/1 only).
|
||||
pyrowave: false,
|
||||
// Conservative: the GameStream + spike paths don't resolve the codec here, and a
|
||||
// Moonlight client may negotiate H264 (whose VAAPI backend can't ingest NV12) — so
|
||||
// they never prefer the producer-native NV12 pod. The punktfunk/1 plane opts in via
|
||||
// `SessionPlan::output_format()`, which knows the codec.
|
||||
nv12_native: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -201,18 +214,26 @@ pub struct CapturedFrame {
|
||||
pub cursor: Option<CursorOverlay>,
|
||||
}
|
||||
|
||||
/// A captured frame still living in a single-plane packed-RGB dmabuf (the VAAPI zero-copy path).
|
||||
/// A captured frame still living in a DMA-BUF. Packed RGB uses one plane. Native Linux NV12
|
||||
/// (gamescope PipeWire) travels in ONE fd: Y starts at `offset`, and the interleaved UV plane
|
||||
/// lives at `plane1`'s offset/stride when the producer reported them — else at the contiguous
|
||||
/// fallback `offset + stride * frame_height` with the shared `stride`.
|
||||
///
|
||||
/// Owns a *dup* of the PipeWire buffer's fd, so the frame can travel to the encode thread and be
|
||||
/// imported into a VA surface there without the compositor's buffer being closed underneath it.
|
||||
/// (Content stability across the brief import window relies on the compositor's buffer pool depth,
|
||||
/// same as any zero-copy capture — the VAAPI importer copies into its own NV12 surface promptly.)
|
||||
/// imported there without the compositor's buffer being closed underneath it. Content stability
|
||||
/// across the brief import window relies on the compositor's buffer pool depth, like any zero-copy
|
||||
/// capture.
|
||||
#[cfg(target_os = "linux")]
|
||||
pub struct DmabufFrame {
|
||||
pub fd: std::os::fd::OwnedFd,
|
||||
/// DRM FourCC of the packed-RGB plane (e.g. `XR24` for BGRx).
|
||||
/// DRM FourCC (`XR24` for BGRx, `NV12` for native 4:2:0).
|
||||
pub fourcc: u32,
|
||||
/// DRM format modifier the compositor allocated (0 = LINEAR).
|
||||
pub modifier: u64,
|
||||
/// Second-plane `(offset, stride)` within the SAME fd, when the producer reported one (the
|
||||
/// PipeWire buffer's plane-1 chunk — NV12's interleaved UV). `None` falls back to the
|
||||
/// contiguous-plane contract above. Always `None` for single-plane packed RGB.
|
||||
pub plane1: Option<(u32, u32)>,
|
||||
pub offset: u32,
|
||||
pub stride: u32,
|
||||
}
|
||||
@@ -225,8 +246,8 @@ pub enum FramePayload {
|
||||
/// The dmabuf has already been imported + copied into this owned device buffer.
|
||||
#[cfg(target_os = "linux")]
|
||||
Cuda(pf_zerocopy::DeviceBuffer),
|
||||
/// A raw packed-RGB dmabuf — the AMD/Intel (VAAPI) zero-copy path. The encoder imports it into
|
||||
/// a VA surface and does RGB→NV12 on the GPU video engine (no host CSC, no upload).
|
||||
/// A raw DMA-BUF: packed RGB for the existing GPU CSC paths, or native NV12 from a producer
|
||||
/// such as gamescope. The encoder imports it without a host copy.
|
||||
#[cfg(target_os = "linux")]
|
||||
Dmabuf(DmabufFrame),
|
||||
/// A GPU-resident D3D11 texture (Windows zero-copy path for NVENC). Owns the copied frame.
|
||||
|
||||
@@ -333,6 +333,11 @@ fn run_inner(mut opts: SessionOpts, mut mode: ModeCtl) -> Result<Option<Outcome>
|
||||
// bottom-right corner (the reported bug). The menu/library is keyboard+gamepad-driven
|
||||
// and consumes no mouse, so nothing wanted these synthetic events anyway.
|
||||
sdl3::hint::set("SDL_TOUCH_MOUSE_EVENTS", "0");
|
||||
// The Wayland `app_id` (and X11 WM_CLASS) — compositors match it against
|
||||
// io.unom.Punktfunk.desktop for the window/taskbar icon. Without it SDL uses a generic
|
||||
// identity and the session window gets the default-Wayland icon (the Linux analog of
|
||||
// the AppUserModelID adoption above).
|
||||
sdl3::hint::set("SDL_APP_ID", "io.unom.Punktfunk");
|
||||
let sdl = sdl3::init().context("SDL init")?;
|
||||
let video = sdl.video().context("SDL video")?;
|
||||
let events = sdl.event().context("SDL events")?;
|
||||
|
||||
@@ -30,9 +30,17 @@ impl Presenter {
|
||||
// switch modes before anything touches this frame. Only where the surface
|
||||
// offers HDR10 — otherwise PQ stays on the SDR swapchain and the CSC shader
|
||||
// tonemaps (mode 1).
|
||||
//
|
||||
// CPU frames NEVER take the HDR10 surface: software decode uploads swscale RGBA with
|
||||
// no CSC/tonemap pass, so on a mode-0 swapchain that sRGB-encoded content would be
|
||||
// composed as PQ — the field-reported psychedelic cyan/magenta picture (reproduced
|
||||
// 2026-07-21: Fedora-class client, no hw HEVC decode, GNOME/Mesa offering HDR10 even
|
||||
// on an SDR desktop). On the SDR swapchain the same frames are merely untonemapped
|
||||
// (washed out) — wrong in the known, benign way until the CPU lane grows a real
|
||||
// PQ→sRGB pass.
|
||||
let frame_pq = match &input {
|
||||
FrameInput::Redraw => None,
|
||||
FrameInput::Cpu(f) => Some(f.color.is_pq()),
|
||||
FrameInput::Cpu(_) => Some(false),
|
||||
#[cfg(target_os = "linux")]
|
||||
FrameInput::Dmabuf(d) => Some(d.color.is_pq()),
|
||||
FrameInput::VkFrame(v) => Some(v.color.is_pq()),
|
||||
|
||||
@@ -617,6 +617,15 @@ pub(super) fn pick_formats(
|
||||
surface: vk::SurfaceKHR,
|
||||
colorspace_ext: bool,
|
||||
) -> Result<(vk::SurfaceFormatKHR, Option<vk::SurfaceFormatKHR>)> {
|
||||
// `PUNKTFUNK_HDR10=0` (explicit-off grammar) refuses the HDR10/ST.2084 swapchain outright,
|
||||
// pinning PQ streams to the shader tonemap on an SDR surface. Two reasons this exists:
|
||||
// desktop compositors newly offer HDR10 even on SDR desktops (GNOME 48 / Plasma 6 with
|
||||
// Mesa ≥ 25.1 — a lane that otherwise engages silently), and it is the A/B lever that
|
||||
// splits "HDR10 passthrough composes wrong" from "the decoded planes are wrong" in the
|
||||
// field without rebuilding anything.
|
||||
let colorspace_ext = colorspace_ext
|
||||
&& !std::env::var("PUNKTFUNK_HDR10")
|
||||
.is_ok_and(|v| matches!(v.as_str(), "0" | "false" | "off" | "no"));
|
||||
let formats = unsafe { surface_i.get_physical_device_surface_formats(pdev, surface) }?;
|
||||
let mut sdr = None;
|
||||
for want in [vk::Format::B8G8R8A8_UNORM, vk::Format::R8G8B8A8_UNORM] {
|
||||
|
||||
@@ -254,6 +254,34 @@ pub fn detect() -> Result<Compositor> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attach-only probes: while any scope is held, backend `create` paths must not stop, relaunch,
|
||||
/// or take over box sessions — they may only attach to an already-live output, and fail fast
|
||||
/// otherwise. The capture-loss rebuild holds one for its first seconds: right after a capture
|
||||
/// loss the active-session detection can be STALE (a Game→Desktop switch observed live: the
|
||||
/// probe's gamescope re-acquire restarted `gamescope-session.target` and yanked the user out of
|
||||
/// the KDE session they had just switched to). A counter, so overlapping scopes compose.
|
||||
static REBUILD_PROBES: std::sync::atomic::AtomicU32 = std::sync::atomic::AtomicU32::new(0);
|
||||
|
||||
/// RAII scope marking pipeline builds as attach-only probes (see [`rebuild_probe_active`]).
|
||||
pub struct RebuildProbeScope(());
|
||||
|
||||
pub fn rebuild_probe_scope() -> RebuildProbeScope {
|
||||
REBUILD_PROBES.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
RebuildProbeScope(())
|
||||
}
|
||||
|
||||
impl Drop for RebuildProbeScope {
|
||||
fn drop(&mut self) {
|
||||
REBUILD_PROBES.fetch_sub(1, std::sync::atomic::Ordering::SeqCst);
|
||||
}
|
||||
}
|
||||
|
||||
/// Is any [`rebuild_probe_scope`] active? Destructive session operations (stop/relaunch/
|
||||
/// takeover-restart) must be skipped while true.
|
||||
pub fn rebuild_probe_active() -> bool {
|
||||
REBUILD_PROBES.load(std::sync::atomic::Ordering::SeqCst) > 0
|
||||
}
|
||||
|
||||
/// Open the virtual-display driver for `compositor`.
|
||||
pub fn open(compositor: Compositor) -> Result<Box<dyn VirtualDisplay>> {
|
||||
#[cfg(target_os = "linux")]
|
||||
|
||||
@@ -325,6 +325,29 @@ fn create_managed_session(client: &str, mode: Mode) -> Result<VirtualOutput> {
|
||||
if steamos_session_present() {
|
||||
return create_managed_session_steamos(mode);
|
||||
}
|
||||
// Attach-only rebuild probe: reuse a live same-mode session, but NEVER stop/relaunch box
|
||||
// sessions — right after a capture loss the caller's session detection can be stale, and a
|
||||
// destructive rebuild here would fight the session the user just switched to.
|
||||
if crate::rebuild_probe_active() {
|
||||
let guard = MANAGED_SESSION.lock().unwrap_or_else(|e| e.into_inner());
|
||||
let same_mode = guard.as_ref().is_some_and(|s| {
|
||||
s.width == mode.width && s.height == mode.height && s.refresh_hz == mode.refresh_hz
|
||||
});
|
||||
if same_mode {
|
||||
if let Some(node_id) = find_gamescope_node() {
|
||||
point_injector_at_eis();
|
||||
tracing::info!(
|
||||
node_id,
|
||||
"gamescope session: attach-only probe reusing live node"
|
||||
);
|
||||
return Ok(managed_output(node_id, mode));
|
||||
}
|
||||
}
|
||||
return Err(anyhow!(
|
||||
"gamescope session has no attachable live node — attach-only rebuild probe refuses \
|
||||
to stop/relaunch box sessions (re-detection follows the live session)"
|
||||
));
|
||||
}
|
||||
// Steam is single-instance: if the box autologged into gaming mode on a physical display (the
|
||||
// Bazzite default — `gamescope-session-plus@ogui-steam` on the TV), that session holds Steam and
|
||||
// renders to the TV's native mode, which we'd capture instead of the client's. Free Steam by
|
||||
@@ -607,12 +630,17 @@ fn write_steamos_dropin(shim_dir: &std::path::Path, mode: Mode) -> Result<()> {
|
||||
if let Some(parent) = path.parent() {
|
||||
std::fs::create_dir_all(parent).with_context(|| format!("mkdir {}", parent.display()))?;
|
||||
}
|
||||
// UnsetEnvironment: the same headless-must-not-attach armor `launch_session` gives its
|
||||
// transient unit — the manager env can carry a stale desktop DISPLAY/WAYLAND_DISPLAY (from a
|
||||
// portal settle), and gamescope would abort trying to attach to it instead of becoming the
|
||||
// display server. Unit-scoped belt-and-suspenders on top of the observe_session_instance scrub.
|
||||
let body = format!(
|
||||
"[Service]\n\
|
||||
Environment=PATH={shim}:/usr/bin:/bin:/usr/local/bin\n\
|
||||
Environment=PF_W={w}\n\
|
||||
Environment=PF_H={h}\n\
|
||||
Environment=PF_HZ={hz}\n",
|
||||
Environment=PF_HZ={hz}\n\
|
||||
UnsetEnvironment=DISPLAY WAYLAND_DISPLAY\n",
|
||||
shim = shim_dir.display(),
|
||||
w = mode.width,
|
||||
h = mode.height,
|
||||
@@ -650,6 +678,16 @@ fn create_managed_session_steamos(mode: Mode) -> Result<VirtualOutput> {
|
||||
}
|
||||
*guard = None; // tracked session lost its node — fall through to a clean restart
|
||||
}
|
||||
// Attach-only rebuild probe: the reuse path above may attach, but a restart of the session
|
||||
// target is out of bounds — observed live on a Deck: a stale post-capture-loss detection made
|
||||
// this restart steal the seat back from the KDE session the user had just switched to.
|
||||
if crate::rebuild_probe_active() {
|
||||
return Err(anyhow!(
|
||||
"gamescope has no live node and this is an attach-only rebuild probe — refusing to \
|
||||
restart {STEAMOS_SESSION_TARGET} (the box may be mid-switch to another session; \
|
||||
re-detection follows it)"
|
||||
));
|
||||
}
|
||||
let shim_dir = write_headless_shim()?;
|
||||
write_steamos_dropin(&shim_dir, mode)?;
|
||||
systemctl_user(&["daemon-reload"]);
|
||||
|
||||
@@ -57,6 +57,13 @@ pub fn observe_session_instance(active: &ActiveSession) {
|
||||
if let Some(old) = compositor_for_kind(prev.0) {
|
||||
registry::invalidate_backend(old.id());
|
||||
}
|
||||
// The dead desktop's socket vars may still sit in the systemd --user manager env
|
||||
// ([`settle_desktop_portal`]'s import-environment) — scrub them NOW, or the next
|
||||
// `gamescope-session.target` start inherits a stale WAYLAND_DISPLAY and gamescope
|
||||
// runs NESTED against the dead desktop socket instead of becoming the display
|
||||
// server ("Failed to connect to wayland socket: wayland-0" — kept a Deck's Game
|
||||
// Mode from starting at all, observed live 2026-07-21).
|
||||
scrub_desktop_manager_env();
|
||||
}
|
||||
let epoch = bump_session_epoch();
|
||||
tracing::info!(
|
||||
@@ -70,6 +77,23 @@ pub fn observe_session_instance(active: &ActiveSession) {
|
||||
*last = Some(cur);
|
||||
}
|
||||
|
||||
/// Counterpart to [`settle_desktop_portal`]'s `import-environment`: drop the desktop session's
|
||||
/// socket vars from the systemd `--user` manager env once that desktop instance is GONE. They
|
||||
/// persist in the manager otherwise, and every later user unit inherits them — including
|
||||
/// `gamescope-session.target`, whose gamescope then aborts trying to attach to the dead desktop
|
||||
/// socket. Best-effort; the D-Bus activation env has no unset op, but gamescope-session is
|
||||
/// systemd-started, so the manager scrub is the one that matters. (A desktop restart re-imports
|
||||
/// via the next [`settle_desktop_portal`], so scrubbing on a bounce is harmless.)
|
||||
#[cfg(target_os = "linux")]
|
||||
fn scrub_desktop_manager_env() {
|
||||
let _ = std::process::Command::new("systemctl")
|
||||
.args(["--user", "unset-environment", "WAYLAND_DISPLAY", "DISPLAY"])
|
||||
.status();
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
fn scrub_desktop_manager_env() {}
|
||||
|
||||
/// Is `kind` a **desktop** compositor (KWin / Mutter / wlroots) — one whose kept PipeWire outputs die
|
||||
/// with the compositor instance, so the session epoch tracks it? `Gaming` (gamescope) and `None` are
|
||||
/// not (gamescope spawns are independent nested sessions — see [`observe_session_instance`]).
|
||||
|
||||
@@ -26,7 +26,10 @@ pub use pf_capture::{dxgi, synthetic_nv12};
|
||||
/// capture→encode cycle). Resolved here (the host facade) and threaded in, so the edge stays one-way
|
||||
/// (plan §2.4 / §W6).
|
||||
#[cfg(target_os = "linux")]
|
||||
fn zero_copy_policy(pyrowave_session: bool) -> pf_capture::ZeroCopyPolicy {
|
||||
fn zero_copy_policy(
|
||||
pyrowave_session: bool,
|
||||
native_nv12_session: bool,
|
||||
) -> pf_capture::ZeroCopyPolicy {
|
||||
let backend_is_vaapi = crate::encode::linux_zero_copy_is_vaapi();
|
||||
// The raw-dmabuf passthrough serves a PyroWave session on ANY vendor (the wavelet encoder's
|
||||
// own Vulkan device imports the dmabuf) — per-session from the negotiated codec, plus the
|
||||
@@ -56,6 +59,7 @@ fn zero_copy_policy(pyrowave_session: bool) -> pf_capture::ZeroCopyPolicy {
|
||||
backend_is_gpu: crate::encode::resolved_backend_is_gpu(),
|
||||
pyrowave_session,
|
||||
pyrowave_modifiers,
|
||||
native_nv12_session,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,7 +74,9 @@ pub fn open_portal_monitor(want_hdr: bool) -> Result<Box<dyn Capturer>> {
|
||||
let anchored = crate::inject::default_backend() == crate::inject::Backend::Libei;
|
||||
// Monitor mirrors never carry the native PyroWave plane (GameStream protocol) — per-session
|
||||
// passthrough is virtual-output-only; the global encoder-pref lever still applies inside.
|
||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false))
|
||||
// Native NV12 stays off too: the mirror path doesn't resolve the codec here, and the desktop
|
||||
// compositors it mirrors (GNOME/KWin) don't produce NV12 anyway.
|
||||
pf_capture::open_portal_monitor(anchored, want_hdr, zero_copy_policy(false, false))
|
||||
}
|
||||
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
@@ -101,7 +107,7 @@ pub fn capture_virtual_output(
|
||||
vout.keepalive,
|
||||
want.gpu,
|
||||
want.chroma_444,
|
||||
zero_copy_policy(want.pyrowave),
|
||||
zero_copy_policy(want.pyrowave, want.nv12_native),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -322,7 +322,33 @@ fn real_main() -> Result<()> {
|
||||
// `PUNKTFUNK_HDR_SHADER_P010` colour math without green-screening a live HDR stream. Prints
|
||||
// PASS/FAIL + max Y/Cb/Cr error.
|
||||
#[cfg(target_os = "windows")]
|
||||
Some("hdr-p010-selftest") => crate::capture::dxgi::hdr_p010_selftest(),
|
||||
Some("hdr-p010-selftest") => {
|
||||
// Optional args: a `WxH` size (default 64x64 — pass the real capture size: heights
|
||||
// like 1080 are NOT 16-aligned and exercise a different driver path) and a GPU
|
||||
// vendor (`intel`|`nvidia`|`amd` — dual-GPU boxes otherwise test the default
|
||||
// adapter, which may not be the one that encodes).
|
||||
let mut size = (64u32, 64u32);
|
||||
let mut vendor = None;
|
||||
for a in args.iter().skip(2) {
|
||||
match a.as_str() {
|
||||
"intel" => vendor = Some(0x8086),
|
||||
"nvidia" => vendor = Some(0x10de),
|
||||
"amd" => vendor = Some(0x1002),
|
||||
s => {
|
||||
let parsed = s
|
||||
.split_once('x')
|
||||
.and_then(|(w, h)| Some((w.parse().ok()?, h.parse().ok()?)));
|
||||
match parsed {
|
||||
Some(wh) => size = wh,
|
||||
None => anyhow::bail!(
|
||||
"hdr-p010-selftest: unrecognized arg {s:?} (want WxH or intel|nvidia|amd)"
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
crate::capture::dxgi::hdr_p010_selftest_at(size.0, size.1, vendor)
|
||||
}
|
||||
// Linux HDR readiness probe (GNOME 50+ portal path): prints whether a monitor is currently
|
||||
// in BT.2100 (HDR) colour mode, whether the NVENC/VAAPI backend probes Main10 for
|
||||
// HEVC/AV1, and the GameStream HDR capability the two combine into — the "why isn't my
|
||||
|
||||
@@ -1111,6 +1111,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan,
|
||||
&quit,
|
||||
&stop,
|
||||
8,
|
||||
Some(bringup.as_ref()),
|
||||
)?;
|
||||
// Setup done — the IDD-push setup lock releases as the guard leaves this arm's scope,
|
||||
@@ -1426,6 +1427,7 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan,
|
||||
&quit,
|
||||
&stop,
|
||||
8,
|
||||
None,
|
||||
)?;
|
||||
Ok((new_vd, pipe))
|
||||
@@ -1522,6 +1524,10 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
bit_depth,
|
||||
plan,
|
||||
&quit,
|
||||
// No first-frame shortening here: this direct call has no retry wrapper to
|
||||
// absorb an early bail, and the resize source is a live compositor (the
|
||||
// takeover race doesn't apply) — keep the patient default.
|
||||
None,
|
||||
Some(resize_trace.as_ref()),
|
||||
) {
|
||||
Ok(next_pipe) => {
|
||||
@@ -1878,7 +1884,15 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
// connected, frozen on the last frame, and the stream resumes when the new output
|
||||
// appears — no reconnect.
|
||||
const REBUILD_BUDGET: std::time::Duration = std::time::Duration::from_secs(40);
|
||||
let rebuild_deadline = std::time::Instant::now() + REBUILD_BUDGET;
|
||||
// Attach-only holdoff: for the first seconds after a capture loss the session
|
||||
// detection can be STALE (the new session isn't up yet), and a rebuild acting on
|
||||
// a stale "Gaming" answer restarts gamescope-session.target — which on SteamOS
|
||||
// steals the seat back from the session the user just switched to (observed
|
||||
// live). While the holdoff lasts, builds run under a vdisplay rebuild-probe
|
||||
// scope: attach to live outputs only, never stop/relaunch/take over sessions.
|
||||
const PROBE_HOLDOFF: std::time::Duration = std::time::Duration::from_secs(4);
|
||||
let loss_at = std::time::Instant::now();
|
||||
let rebuild_deadline = loss_at + REBUILD_BUDGET;
|
||||
let (new_cap, new_enc, new_frame, new_interval, new_node_id, new_display_gen) = loop {
|
||||
// Follow the active session unless an explicit PUNKTFUNK_COMPOSITOR pin forbids
|
||||
// retargeting (then we stick to the pinned backend and just rebuild it).
|
||||
@@ -1912,6 +1926,8 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
}
|
||||
}
|
||||
let _probe = (loss_at.elapsed() < PROBE_HOLDOFF)
|
||||
.then(crate::vdisplay::rebuild_probe_scope);
|
||||
match build_pipeline_with_retry(
|
||||
&mut vd,
|
||||
cur_mode,
|
||||
@@ -1920,6 +1936,16 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
plan,
|
||||
&quit,
|
||||
&stop,
|
||||
// 1, not 8: this loop re-detects the active session per iteration — short
|
||||
// inner cycles are what let it FOLLOW a session switch instead of burning
|
||||
// retries against a compositor that no longer exists. One attempt per
|
||||
// cycle also keeps every probe on the SHORT first-frame window (attempt 1
|
||||
// = 2.5 s): a patient 10 s attempt here just waits on a stale backend
|
||||
// (observed live: it made a Game→Desktop switch 20 s instead of ~9 —
|
||||
// the winning KWin rebuild took 0.7 s once detection caught up). The
|
||||
// slow-new-session case is the OUTER loop's job (40 s budget, fresh
|
||||
// 2.5 s probes until the new compositor delivers).
|
||||
1,
|
||||
None,
|
||||
) {
|
||||
Ok(p) => break p,
|
||||
@@ -1932,6 +1958,9 @@ pub(super) fn virtual_stream(ctx: SessionContext, prepared: Option<PreparedDispl
|
||||
}
|
||||
tracing::warn!(error = %format!("{e2:#}"),
|
||||
"capture lost — new session not up yet, retrying");
|
||||
// Probe failures are instant (attach-only bail) — pace the loop so
|
||||
// re-detection runs at ~2 Hz instead of spinning.
|
||||
std::thread::sleep(std::time::Duration::from_millis(500));
|
||||
}
|
||||
}
|
||||
};
|
||||
@@ -2655,6 +2684,7 @@ pub(super) fn prepare_display(
|
||||
plan,
|
||||
quit,
|
||||
stop,
|
||||
8,
|
||||
Some(trace),
|
||||
)?;
|
||||
Ok(PreparedDisplay { vd, pipeline })
|
||||
@@ -2679,15 +2709,22 @@ fn build_pipeline_with_retry(
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
quit: &Arc<AtomicBool>,
|
||||
stop: &Arc<AtomicBool>,
|
||||
// Retry budget: 8 everywhere EXCEPT the capture-loss rebuild (2). That path wraps this call
|
||||
// in its own outer loop that RE-DETECTS the active session between calls — during a
|
||||
// Gaming↔Desktop switch the old compositor is simply gone, so burning 8 attempts (~13 s)
|
||||
// against its dead socket only delays following the box to the session that replaced it
|
||||
// (observed live: a Desktop→Gaming switch spent 13 of its 27 s retrying gone-KWin).
|
||||
max_attempts: u32,
|
||||
// Transition trace (P0.1): `Some` for the traced builds (bring-up, resize); each stage stamps
|
||||
// once (first crossing) so the retry loop can pass it through unconditionally.
|
||||
trace: Option<&crate::bringup::Trace>,
|
||||
) -> Result<Pipeline> {
|
||||
// ~10s first-frame wait per attempt. 8 gives a ~90s budget for the SLOW case: a host-managed
|
||||
// gamescope session cold-starting Steam Big Picture (the SteamOS/Bazzite takeover) can take
|
||||
// 30-60s to produce its first frame, and a first-connect timeout would tear down the warm
|
||||
// session (forcing another cold start on reconnect). A genuinely permanent failure still fails
|
||||
// fast via `is_permanent_build_error`; only transient "no frame yet" retries consume the budget.
|
||||
// ~10s first-frame wait per attempt (attempt 1: see FIRST_ATTEMPT_FRAME_BUDGET below). 8
|
||||
// gives a ~80s budget for the SLOW case: a host-managed gamescope session cold-starting Steam
|
||||
// Big Picture (the SteamOS/Bazzite takeover) can take 30-60s to produce its first frame, and
|
||||
// a first-connect timeout would tear down the warm session (forcing another cold start on
|
||||
// reconnect). A genuinely permanent failure still fails fast via `is_permanent_build_error`;
|
||||
// only transient "no frame yet" retries consume the budget.
|
||||
// IDD-push only: HOLD one monitor lease across all build attempts. A failed attempt's capturer
|
||||
// drop releases ITS lease, but this held lease keeps the shared monitor Active (refs >= 1), so the
|
||||
// next attempt's `vd.create` JOINS it (refcount++) instead of finding it Lingering and tripping the
|
||||
@@ -2705,9 +2742,16 @@ fn build_pipeline_with_retry(
|
||||
} else {
|
||||
None
|
||||
};
|
||||
const MAX_ATTEMPTS: u32 = 8;
|
||||
// Attempt 1 waits only briefly for the first frame: a PipeWire stream connected while
|
||||
// gamescope re-initializes its headless takeover negotiates a format and reaches `Streaming`
|
||||
// but never receives a buffer — a FRESH connect then delivers within ~0.5 s (observed on
|
||||
// SteamOS: every gamescope bring-up burned the full 10 s on attempt 1, then attempt 2 got
|
||||
// frames instantly → 17 s bring-ups). Healthy compositors deliver the first frame well inside
|
||||
// this window (KWin ~0.3 s), and the genuinely-slow cold start above still gets the patient
|
||||
// 10 s window on every later attempt.
|
||||
const FIRST_ATTEMPT_FRAME_BUDGET: std::time::Duration = std::time::Duration::from_millis(2500);
|
||||
let mut backoff = std::time::Duration::from_millis(500);
|
||||
for attempt in 1..=MAX_ATTEMPTS {
|
||||
for attempt in 1..=max_attempts {
|
||||
// The client is gone (connection closed → `stop`): every further attempt only churns the
|
||||
// box for a session no one is watching — on a Bazzite takeover that means SIGKILLing and
|
||||
// relaunching the box's Steam session once per attempt for minutes (the .181 storm
|
||||
@@ -2719,7 +2763,17 @@ fn build_pipeline_with_retry(
|
||||
attempt - 1
|
||||
);
|
||||
}
|
||||
match build_pipeline(vd, mode, bitrate_kbps, bit_depth, plan, quit, trace) {
|
||||
let first_frame_budget = (attempt == 1).then_some(FIRST_ATTEMPT_FRAME_BUDGET);
|
||||
match build_pipeline(
|
||||
vd,
|
||||
mode,
|
||||
bitrate_kbps,
|
||||
bit_depth,
|
||||
plan,
|
||||
quit,
|
||||
first_frame_budget,
|
||||
trace,
|
||||
) {
|
||||
Ok(pipe) => {
|
||||
if attempt > 1 {
|
||||
tracing::info!(attempt, "pipeline up after retry");
|
||||
@@ -2729,7 +2783,7 @@ fn build_pipeline_with_retry(
|
||||
Err(e) => {
|
||||
let chain = format!("{e:#}");
|
||||
let permanent = is_permanent_build_error(&chain);
|
||||
if permanent || attempt == MAX_ATTEMPTS {
|
||||
if permanent || attempt == max_attempts {
|
||||
let why = if permanent {
|
||||
"permanent"
|
||||
} else {
|
||||
@@ -2741,7 +2795,7 @@ fn build_pipeline_with_retry(
|
||||
}
|
||||
tracing::warn!(
|
||||
attempt,
|
||||
max = MAX_ATTEMPTS,
|
||||
max = max_attempts,
|
||||
backoff_ms = backoff.as_millis() as u64,
|
||||
error = %chain,
|
||||
"pipeline build failed — retrying"
|
||||
@@ -2802,6 +2856,10 @@ fn build_pipeline(
|
||||
bit_depth: u8,
|
||||
plan: crate::session_plan::SessionPlan,
|
||||
quit: &Arc<AtomicBool>,
|
||||
// First-frame wait override (`None` = the backend's default 10 s): the retry loop shortens
|
||||
// its FIRST attempt so a stream stuck in the gamescope takeover race fails over to the
|
||||
// reconnect that fixes it (see FIRST_ATTEMPT_FRAME_BUDGET in `build_pipeline_with_retry`).
|
||||
first_frame_budget: Option<std::time::Duration>,
|
||||
// Transition trace (P0.1): stamps the build's stages (display acquire, capture attach, first
|
||||
// frame, encoder open) into the bring-up/resize timeline. `None` on untraced rebuilds.
|
||||
trace: Option<&crate::bringup::Trace>,
|
||||
@@ -2857,7 +2915,11 @@ fn build_pipeline(
|
||||
t.mark("capture_attached");
|
||||
}
|
||||
capturer.set_active(true);
|
||||
let frame = match capturer.next_frame().context("first frame") {
|
||||
let first = match first_frame_budget {
|
||||
Some(budget) => capturer.next_frame_within(budget),
|
||||
None => capturer.next_frame(),
|
||||
};
|
||||
let frame = match first.context("first frame") {
|
||||
Ok(f) => f,
|
||||
Err(e) => {
|
||||
// A reused kept display was dead — invalidate it so the next attempt creates fresh (A2).
|
||||
|
||||
@@ -179,6 +179,13 @@ impl SessionPlan {
|
||||
// Vulkan device; on Linux the capture facade flips the zero-copy policy to the
|
||||
// raw-dmabuf passthrough (see above).
|
||||
pyrowave: self.codec == crate::encode::Codec::PyroWave,
|
||||
// Producer-native NV12 (gamescope) is consumable only by the Linux Vulkan Video
|
||||
// backend — resolved HERE from the plan's codec so the capturer never reaches back
|
||||
// into encode (the same one-way edge as `gpu` above).
|
||||
#[cfg(target_os = "linux")]
|
||||
nv12_native: crate::encode::linux_native_nv12_ok(self.codec),
|
||||
#[cfg(not(target_os = "linux"))]
|
||||
nv12_native: false,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -101,10 +101,13 @@ ok "build deps ready"
|
||||
|
||||
# --- 2. build host (+ web) -------------------------------------------------
|
||||
log "Building punktfunk-host (release) — first build is slow (~10-15 min)"
|
||||
# vulkan-encode matches the packaged builds (deb/arch): the raw Vulkan Video HEVC/AV1 backend
|
||||
# (real RFI loss recovery). Pure-Rust ash — no extra system dep. A featureless hand build would
|
||||
# silently fall back to libav VAAPI.
|
||||
distrobox enter "$BOX" -- bash -lc "
|
||||
set -e
|
||||
export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'
|
||||
cd '$SRC' && cargo build -r -p punktfunk-host
|
||||
cd '$SRC' && cargo build -r -p punktfunk-host --features punktfunk-host/vulkan-encode
|
||||
"
|
||||
[ -x "$BIN" ] || die "build did not produce $BIN"
|
||||
ok "host binary: $BIN"
|
||||
@@ -126,8 +129,12 @@ mkdir -p "$CONFIG"
|
||||
if [ ! -f "$CONFIG/host.env" ]; then
|
||||
cat > "$CONFIG/host.env" <<'EOF'
|
||||
# punktfunk Steam Deck host config (sourced by the punktfunk-host user service).
|
||||
# Auto encoder: VAAPI on the Deck's AMD GPU, NVENC on NVIDIA.
|
||||
# Auto encoder: Vulkan Video (or VAAPI fallback) on the Deck's AMD GPU, NVENC on NVIDIA.
|
||||
PUNKTFUNK_ENCODER=auto
|
||||
# Van Gogh (LCD/OLED Deck) RADV still gates VK_KHR_video_encode_* behind this perftest flag;
|
||||
# without it the Vulkan backend can't open and sessions fall back to libav VAAPI. Harmless on
|
||||
# GPUs where encode is exposed by default.
|
||||
RADV_PERFTEST=video_encode
|
||||
# The host auto-detects the live session (Game Mode gamescope / Desktop KDE) per connect.
|
||||
# Override the compositor only if detection misbehaves: PUNKTFUNK_COMPOSITOR=gamescope
|
||||
EOF
|
||||
@@ -136,6 +143,16 @@ else
|
||||
ok "host.env exists (left as-is)"
|
||||
fi
|
||||
|
||||
# KWin authorization for Desktop-Mode streaming (and mid-stream Game↔Desktop switches): KWin
|
||||
# resolves a connecting client's /proc/<pid>/exe against a .desktop `Exec=` and only then grants
|
||||
# the restricted Wayland globals it lists (see packaging/linux/io.unom.Punktfunk.Host.desktop).
|
||||
# Exec must therefore be THIS install's binary path, not the packaged /usr/bin one. KWin reads
|
||||
# grants at session start — after first install, restart the Desktop session (Game Mode and back).
|
||||
mkdir -p "$HOME/.local/share/applications"
|
||||
sed "s|^Exec=.*|Exec=$BIN|" "$SRC/packaging/linux/io.unom.Punktfunk.Host.desktop" \
|
||||
> "$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
|
||||
ok "KWin desktop-capture authorization (io.unom.Punktfunk.Host.desktop → $BIN)"
|
||||
|
||||
if [ "$WITH_WEB" = 1 ] && [ ! -f "$CONFIG/web.env" ]; then
|
||||
# Random login password + session secret for the web console, generated once.
|
||||
# `|| true` swallows the SIGPIPE `tr` takes when `head` closes the pipe (pipefail would abort).
|
||||
|
||||
@@ -21,7 +21,8 @@ if [ "${1:-}" = "--pull" ]; then
|
||||
fi
|
||||
|
||||
log "Rebuilding host (release)"
|
||||
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'; cd '$SRC' && cargo build -r -p punktfunk-host"
|
||||
# vulkan-encode matches the packaged builds (deb/arch) — see install.sh.
|
||||
distrobox enter "$BOX" -- bash -lc "set -e; export PATH=\$HOME/.cargo/bin:\$PATH CARGO_TARGET_DIR='$TARGET_DIR'; cd '$SRC' && cargo build -r -p punktfunk-host --features punktfunk-host/vulkan-encode"
|
||||
ok "host rebuilt"
|
||||
if [ "$WEB" = 1 ]; then
|
||||
log "Rebuilding web console"
|
||||
@@ -29,6 +30,20 @@ if [ "$WEB" = 1 ]; then
|
||||
ok "web rebuilt"
|
||||
fi
|
||||
|
||||
# Retrofit config that install.sh now writes but older installs predate (both idempotent):
|
||||
# RADV_PERFTEST — Van Gogh RADV still gates VK_KHR_video_encode_* behind it; without it the
|
||||
# Vulkan backend can't open and sessions silently fall back to libav VAAPI. The KWin .desktop —
|
||||
# KWin only grants the restricted capture/input globals to the exe a .desktop authorizes.
|
||||
HOST_ENV="$HOME/.config/punktfunk/host.env"
|
||||
if [ -f "$HOST_ENV" ] && ! grep -q '^RADV_PERFTEST=' "$HOST_ENV"; then
|
||||
printf '\n# Van Gogh RADV gates VK_KHR_video_encode_* behind this (Vulkan Video encode).\nRADV_PERFTEST=video_encode\n' >> "$HOST_ENV"
|
||||
ok "host.env: added RADV_PERFTEST=video_encode"
|
||||
fi
|
||||
mkdir -p "$HOME/.local/share/applications"
|
||||
sed "s|^Exec=.*|Exec=$TARGET_DIR/release/punktfunk-host|" "$SRC/packaging/linux/io.unom.Punktfunk.Host.desktop" \
|
||||
> "$HOME/.local/share/applications/io.unom.Punktfunk.Host.desktop"
|
||||
ok "KWin desktop-capture authorization refreshed"
|
||||
|
||||
log "Restarting services"
|
||||
systemctl --user restart punktfunk-host.service
|
||||
ok "punktfunk-host restarted"
|
||||
|
||||
Reference in New Issue
Block a user