diff --git a/crates/pf-client-core/src/video.rs b/crates/pf-client-core/src/video.rs index 678f2a8c..96ee8c78 100644 --- a/crates/pf-client-core/src/video.rs +++ b/crates/pf-client-core/src/video.rs @@ -110,6 +110,21 @@ pub struct VkVideoFrame { pub decode_done_value: u64, pub width: u32, pub height: u32, + /// The decode POOL's allocated extent (`AVHWFramesContext.width`/`.height`) — the + /// CODED picture size (rounded up to the codec's macroblock alignment, then to the + /// driver's Vulkan picture-access granularity), so it is `>=` `width`/`height`. At + /// 1080p the pool is 1088 rows tall: 1080 is not a multiple of 16. + /// + /// The presenter samples this image with NORMALIZED coordinates, so it needs both + /// numbers — `width`/`height` is what to display, `coded_*` is what the texture + /// actually spans. Sampling `0..1` without the ratio stretches the alignment padding + /// into view; because encoders fill those rows by replicating the picture's last + /// line, that reads as the bottom row smeared over the final few rows of the image + /// (field report 2026-07-31). Same class as the D3D11VA source-rect clamp in + /// `crate::video_d3d11`, which shows as a green bar there only because DXVA padding + /// is left uninitialized rather than replicated. + pub coded_width: u32, + pub coded_height: u32, pub color: ColorDesc, /// Intra keyframe (IDR/I): the stream's re-anchor point. The pump resumes display on /// one after suppressing the concealed frames a reference loss leaves in its wake (on diff --git a/crates/pf-client-core/src/video_vulkan.rs b/crates/pf-client-core/src/video_vulkan.rs index e76bbdaa..6650bafc 100644 --- a/crates/pf-client-core/src/video_vulkan.rs +++ b/crates/pf-client-core/src/video_vulkan.rs @@ -382,6 +382,13 @@ impl VulkanDecoder { // sem_value was last written by the decode submission on THIS thread. let timeline_sem = (*vkf).sem[0] as u64; let decode_done_value = (*vkf).sem_value[0]; + log_layout_once( + (*self.frame).width, + (*self.frame).height, + (*fc).width, + (*fc).height, + sw, + ); Ok(VkVideoFrame { vkframe: vkf as usize, frames_ctx: fc as usize, @@ -392,6 +399,13 @@ impl VulkanDecoder { decode_done_value, width: (*self.frame).width as u32, height: (*self.frame).height as u32, + // The pool extent, not the frame's: `avcodec_get_hw_frames_parameters` + // sizes it from `coded_width`/`coded_height` and FFmpeg's Vulkan layer + // rounds that up again to the driver's picture-access granularity. The + // `max` is defensive — a pool SMALLER than the frame would mean sampling + // past the surface, so degrade to "no crop" rather than trust it. + coded_width: ((*fc).width.max((*self.frame).width)) as u32, + coded_height: ((*fc).height.max((*self.frame).height)) as u32, color: ColorDesc::from_raw(self.frame), keyframe: frame_is_keyframe(self.frame), guard: DrmFrameGuard(clone), @@ -400,6 +414,30 @@ impl VulkanDecoder { } } +/// One-time dump of the first decoded frame's layout — the forensics for a new GPU/driver. +/// `pool_*` is the allocated decode surface (`>=` the frame); the gap is the alignment +/// padding the presenter's UV scale excludes. The D3D11VA path logs the same pair. +fn log_layout_once( + width: i32, + height: i32, + pool_w: i32, + pool_h: i32, + sw: ffmpeg::ffi::AVPixelFormat, +) { + use std::sync::atomic::{AtomicBool, Ordering}; + static ONCE: AtomicBool = AtomicBool::new(true); + if ONCE.swap(false, Ordering::Relaxed) { + tracing::info!( + width, + height, + pool_w, + pool_h, + ?sw, + "Vulkan Video first frame" + ); + } +} + impl Drop for VulkanDecoder { fn drop(&mut self) { use ffmpeg::ffi; diff --git a/crates/pf-presenter/shaders/nv12_csc.frag b/crates/pf-presenter/shaders/nv12_csc.frag index 8444aaaa..96512eb3 100644 --- a/crates/pf-presenter/shaders/nv12_csc.frag +++ b/crates/pf-presenter/shaders/nv12_csc.frag @@ -15,6 +15,14 @@ // reference white, BT.2020→709 primaries, a soft maxRGB rolloff for highlights // (BT.2390-flavored simplicity, not libplacebo), then sRGB encode. // params.y = tonemap peak (display-relative, ~= peak_nits / 203). +// params.zw = the crop→surface UV scale (frame size / decode-pool size). A Vulkan-Video +// pool image is the CODED surface, taller than the picture whenever the height is +// not a multiple of the driver's alignment (1080 → 1088); sampling the full 0..1 +// would drag those padding rows into view — and since encoders fill them by +// replicating the last picture line, that reads as the bottom row smeared over the +// final few rows. 1.0/1.0 for every path whose image is already crop-sized (dmabuf +// imports the planes at the crop over the real stride; D3D11VA clamps in its +// VideoProcessor blit). // // Regenerate: shaders/build.sh (committed .spv, no build-time toolchain). #version 450 @@ -29,7 +37,7 @@ layout(push_constant) uniform Csc { vec4 r0; vec4 r1; vec4 r2; - vec4 params; // x: mode, y: tonemap peak, z/w: reserved + vec4 params; // x: mode, y: tonemap peak, zw: crop/pool UV scale } pc; // SMPTE ST.2084 (PQ) EOTF: code value → display-referred linear, normalized to 1.0 = @@ -62,17 +70,22 @@ vec3 srgb_oetf(vec3 c) { } void main() { + // Crop to the visible picture: the triangle spans the whole render target, so its 0..1 + // maps onto the pool surface only after this scale (see params.zw above). + vec2 uv = v_uv * pc.params.zw; // 4:2:0 chroma is left-cosited (H.273 type 0 — the default inference when unsignaled, and // what the hosts produce), but sampling the half-res plane at the luma UV assumes CENTER // siting — a ~0.5-luma-px rightward chroma shift on hard colored edges. Offset +0.25 chroma // texels to re-align (the same correction the Apple/Windows clients apply). Self-disables // when the plane widths match (a full-size 4:4:4 chroma plane needs no correction). - vec2 cuv = v_uv; + // textureSize is the POOL's chroma width, which is the space `uv` is already in — so the + // offset stays a true quarter-texel whatever the crop. + vec2 cuv = uv; int cw = textureSize(u_c, 0).x; if (cw < textureSize(u_y, 0).x) { cuv.x += 0.25 / float(cw); } - vec3 yuv = vec3(texture(u_y, v_uv).r, texture(u_c, cuv).rg); + vec3 yuv = vec3(texture(u_y, uv).r, texture(u_c, cuv).rg); vec3 rgb = vec3( dot(pc.r0.xyz, yuv) + pc.r0.w, dot(pc.r1.xyz, yuv) + pc.r1.w, diff --git a/crates/pf-presenter/shaders/nv12_csc.frag.spv b/crates/pf-presenter/shaders/nv12_csc.frag.spv index 4579428a..8760f7b6 100644 Binary files a/crates/pf-presenter/shaders/nv12_csc.frag.spv and b/crates/pf-presenter/shaders/nv12_csc.frag.spv differ diff --git a/crates/pf-presenter/src/vk/present.rs b/crates/pf-presenter/src/vk/present.rs index 250065e2..7e5f0693 100644 --- a/crates/pf-presenter/src/vk/present.rs +++ b/crates/pf-presenter/src/vk/present.rs @@ -248,9 +248,12 @@ impl Presenter { height: v.height, }; let ten_bit = f.is_p010(); + // No crop: `dmabuf::import` already creates the plane images at the frame + // size over the surface's real stride, so 0..1 spans exactly the picture. self.record_csc( v.framebuffer, extent, + [1.0, 1.0], f.color, if ten_bit { 10 } else { 8 }, ten_bit, @@ -322,9 +325,17 @@ impl Presenter { }; let ten_bit = f.vk_format == vk::Format::G10X6_B10X6R10X6_2PLANE_420_UNORM_3PACK16.as_raw(); + // The one path that samples a surface BIGGER than the picture: FFmpeg's + // pool is the coded size (1080 → 1088 rows). Scale the UVs to the visible + // crop or the alignment padding — the last picture row, replicated by the + // encoder — is stretched into the bottom of the image. self.record_csc( v.framebuffer, extent, + [ + f.width as f32 / f.coded_width as f32, + f.height as f32 / f.coded_height as f32, + ], f.color, if ten_bit { 10 } else { 8 }, ten_bit, @@ -625,7 +636,11 @@ impl Presenter { /// Record the NV12→RGBA CSC pass into the video image (framebuffer): fullscreen /// triangle, CICP-driven push-constant rows. Shared by the dmabuf and Vulkan-Video - /// paths — only the plane views bound beforehand differ. + /// paths — only the plane views bound beforehand, and `uv_scale`, differ. + /// + /// `extent` is the picture (the framebuffer's own size); `uv_scale` is picture/surface + /// per axis, i.e. `[1.0, 1.0]` unless the bound planes are a decode pool allocated + /// larger than the picture. See the shader's `params.zw` for why that happens. /// /// # Safety /// `self.cmd_buf` must be in the recording state; the CSC descriptor set must point @@ -634,6 +649,7 @@ impl Presenter { &self, framebuffer: vk::Framebuffer, extent: vk::Extent2D, + uv_scale: [f32; 2], color: pf_client_core::video::ColorDesc, depth: u8, msb_packed: bool, @@ -702,6 +718,9 @@ impl Presenter { pc[..12].copy_from_slice(bytemuck_rows(&rows)); pc[12] = mode; pc[13] = peak; + // Crop: 1.0 unless the source image is a decode pool bigger than the picture. + pc[14] = uv_scale[0]; + pc[15] = uv_scale[1]; let bytes = std::slice::from_raw_parts(pc.as_ptr().cast::(), 64); self.device.cmd_push_constants( self.cmd_buf,