feat(hdr): Windows HDR10 + 10-bit end-to-end, negotiated; non-blocking capture recovery
apple / swift (push) Successful in 54s
ci / rust (push) Successful in 1m32s
android / android (push) Successful in 1m49s
ci / web (push) Successful in 26s
ci / docs-site (push) Successful in 30s
ci / bench (push) Successful in 1m36s
decky / build-publish (push) Successful in 12s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 5s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 4s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 4s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 4s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 3s
deb / build-publish (push) Successful in 2m20s
flatpak / build-publish (push) Successful in 4m6s
rpm / build-publish (bazzite, punktfunk-fedora-rpm) (push) Successful in 5m11s
docker / deploy-docs (push) Successful in 18s
rpm / build-publish (fedora-44, punktfunk-fedora44-rpm) (push) Successful in 4m32s

Adds true HDR (BT.2020 PQ) and 10-bit (HEVC Main10) streaming, negotiated so an
8-bit/SDR client is never sent a stream it can't decode, plus a robust fix for the
capture losing the stream across a secure-desktop transition.

Protocol (punktfunk-core/quic.rs):
- Hello gains `video_caps` (VIDEO_CAP_10BIT / VIDEO_CAP_HDR), Welcome gains `bit_depth`,
  both as optional trailing bytes (back-compat). client-rs advertises 10-bit via
  PUNKTFUNK_CLIENT_10BIT; the connector advertises 0 for now (in-band detection drives
  the native clients). Regenerated punktfunk_core.h.

Windows host:
- 10-bit Main10: host enables it only when the client advertised VIDEO_CAP_10BIT AND
  PUNKTFUNK_10BIT is set; threaded through open_video → NVENC (profile Main10,
  pixelBitDepthMinus8).
- HDR: when the captured desktop is scRGB FP16 (R16G16B16A16_FLOAT, HDR on), copy it to
  an FP16 surface, composite the cursor there, convert scRGB → BT.2020 PQ 10-bit
  (R10G10B10A2) via a shader, and encode HEVC Main10 with the BT.2020/PQ colour VUI
  (ABGR10 input). Fixes the freeze + cursor-trail that came from feeding FP16 into the
  BGRA path. Reacts dynamically to the HDR toggle.
- Capture recovery: rebuild is now a single NON-BLOCKING attempt, throttled to ~4×/s,
  repeating the last good frame between attempts (format-tagged last_present). During a
  secure-desktop dwell SudoVDA's output is gone; the old blocking 12 s retry starved the
  send loop for seconds so the client timed out and disconnected — now the session stays
  fed (frozen) until the desktop returns. Also seeds a black frame on recovery.

Apple client (PunktfunkKit):
- Detects HDR in-band from the stream VUI (PQ transfer function), decodes to 10-bit P010,
  and presents via an rgba16Float + BT.2020 PQ CAMetalLayer with EDR; SDR path unchanged.
  Switches automatically on a mid-session HDR toggle.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-15 20:28:52 +00:00
parent f5eae24c87
commit bbabc04bca
19 changed files with 785 additions and 129 deletions
+15
View File
@@ -103,6 +103,9 @@ fn nvenc_input(format: PixelFormat) -> (Pixel, bool) {
PixelFormat::Rgba => (Pixel::RGBA, false),
PixelFormat::Rgb => (Pixel::RGBZ, true), // RGB -> rgb0
PixelFormat::Bgr => (Pixel::BGRZ, true), // BGR -> bgr0
// 10-bit HDR (R10G10B10A2) is produced only by the Windows DXGI HDR capture path; the Linux
// capturer never emits it. Map to BGRA so the match is exhaustive — unreachable here.
PixelFormat::Rgb10a2 => (Pixel::BGRA, false),
}
}
@@ -131,6 +134,7 @@ pub struct NvencEncoder {
unsafe impl Send for NvencEncoder {}
impl NvencEncoder {
#[allow(clippy::too_many_arguments)]
pub fn open(
codec: Codec,
format: PixelFormat,
@@ -139,7 +143,18 @@ impl NvencEncoder {
fps: u32,
bitrate_bps: u64,
cuda: bool,
bit_depth: u8,
) -> Result<Self> {
// TODO(hdr): Linux 10-bit parity. Unlike the Windows raw-SDK path (which upconverts 8-bit
// ARGB → Main10 via pixelBitDepthMinus8), libavcodec hevc_nvenc needs a 10-bit input pixel
// format (p010) for Main10, so it's a bigger change; deferred until a Linux GPU box is
// available to validate. The Linux host stays 8-bit for now.
if bit_depth != 8 {
tracing::warn!(
bit_depth,
"Linux NVENC 10-bit not yet wired — encoding 8-bit"
);
}
ffmpeg::init().context("ffmpeg init")?;
if std::env::var_os("PUNKTFUNK_FFMPEG_DEBUG").is_some() {
unsafe { ffi::av_log_set_level(48) }; // AV_LOG_DEBUG — surface NVENC hw-frame rejects
+70 -10
View File
@@ -43,6 +43,12 @@ pub struct NvencD3d11Encoder {
fps: u32,
bitrate_bps: u64,
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT,
/// Encoded bit depth (8 or 10). 10 → HEVC Main10 (NVENC upconverts the 8-bit ARGB input).
bit_depth: u8,
/// HDR: the capturer is delivering BT.2020 PQ 10-bit (`PixelFormat::Rgb10a2`) frames. Sets the
/// `ABGR10` input format + the BT.2020/PQ colour VUI. Derived per-frame from the capture format
/// (HDR can toggle mid-session); a change re-inits the session.
hdr: bool,
/// Registrations of the capturer's input textures, cached by texture raw pointer — NVENC encodes
/// them in place (no per-frame copy). The cloned `ID3D11Texture2D` keeps each alive until we
/// unregister it (the capturer may drop its copy on a device recreate before our teardown runs).
@@ -71,6 +77,7 @@ impl NvencD3d11Encoder {
height: u32,
fps: u32,
bitrate_bps: u64,
bit_depth: u8,
) -> Result<Self> {
Ok(Self {
encoder: ptr::null_mut(),
@@ -80,6 +87,8 @@ impl NvencD3d11Encoder {
fps,
bitrate_bps,
buffer_fmt: nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB,
bit_depth,
hdr: false,
regs: HashMap::new(),
next: 0,
bitstreams: Vec::new(),
@@ -139,7 +148,8 @@ impl NvencD3d11Encoder {
// it at low pixel rates). Env override PUNKTFUNK_SPLIT_ENCODE = 0/disable | 1/auto | 2 | 3.
// HEVC/AV1 only; the init-failure fallback below disables it if a codec/config rejects it.
let pixel_rate = self.width as u64 * self.height as u64 * self.fps.max(1) as u64;
let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref() {
let mut split_mode: u32 = match std::env::var("PUNKTFUNK_SPLIT_ENCODE").ok().as_deref()
{
Some("0") | Some("disable") => {
nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32
}
@@ -202,6 +212,33 @@ impl NvencD3d11Encoder {
cfg.rcParams.vbvBufferSize = vbv;
cfg.rcParams.vbvInitialDelay = vbv;
// 3b. 10-bit HEVC Main10. The 8-bit ARGB capture input is upconverted by NVENC (the
// proven high-bit-depth-from-8-bit path); the encoded stream is 10-bit, which removes
// banding and is the foundation for HDR. Color stays BT.709 here (Phase 2 sets the
// BT.2020/PQ VUI + HDR10 metadata). 8-bit leaves the preset default (Main) untouched.
if self.bit_depth == 10 {
cfg.profileGUID = nv::NV_ENC_HEVC_PROFILE_MAIN10_GUID;
cfg.encodeCodecConfig.hevcConfig.set_pixelBitDepthMinus8(2);
// 10 - 8
}
// 3c. HDR colour signaling: BT.2020 primaries + SMPTE ST 2084 (PQ) transfer in the
// HEVC VUI, so a decoder/display knows the 10-bit samples are PQ HDR (not SDR gamma).
// The capturer already produced PQ-encoded BT.2020 pixels; this just describes them.
// (HDR10 static metadata — mastering display + MaxCLL/MaxFALL — is added in a follow-up.)
if self.hdr {
let vui = &mut cfg.encodeCodecConfig.hevcConfig.hevcVUIParameters;
vui.videoSignalTypePresentFlag = 1;
vui.videoFullRangeFlag = 0; // limited (studio) range — NVENC RGB→YUV default
vui.colourDescriptionPresentFlag = 1;
vui.colourPrimaries =
nv::NV_ENC_VUI_COLOR_PRIMARIES::NV_ENC_VUI_COLOR_PRIMARIES_BT2020;
vui.transferCharacteristics =
nv::NV_ENC_VUI_TRANSFER_CHARACTERISTIC::NV_ENC_VUI_TRANSFER_CHARACTERISTIC_SMPTE2084;
vui.colourMatrix =
nv::NV_ENC_VUI_MATRIX_COEFFS::NV_ENC_VUI_MATRIX_COEFFS_BT2020_NCL;
}
// 4. initialize the encoder.
let mut init = nv::NV_ENC_INITIALIZE_PARAMS {
version: nv::NV_ENC_INITIALIZE_PARAMS_VER,
@@ -242,9 +279,11 @@ impl NvencD3d11Encoder {
// fails, the codec/config may not accept it (e.g. H264) — disable split and retry
// single-engine rather than fail the session.
Err(e)
if split_mode != nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32
if split_mode
!= nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_AUTO_MODE as u32
&& split_mode
!= nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE as u32 =>
!= nv::NV_ENC_SPLIT_ENCODE_MODE::NV_ENC_SPLIT_DISABLE_MODE
as u32 =>
{
let _ = (API.destroy_encoder)(enc);
tracing::warn!(error = ?e, "NVENC init rejected with split-encode forced — disabling split, retrying single-engine");
@@ -253,7 +292,10 @@ impl NvencD3d11Encoder {
}
Err(e) => {
let _ = (API.destroy_encoder)(enc);
return Err(anyhow!("initialize_encoder: {e:?} (even at {} Mbps floor)", FLOOR_BPS / 1_000_000));
return Err(anyhow!(
"initialize_encoder: {e:?} (even at {} Mbps floor)",
FLOOR_BPS / 1_000_000
));
}
}
};
@@ -280,10 +322,12 @@ impl NvencD3d11Encoder {
}
self.inited = true;
tracing::info!(
"NVENC D3D11 session: {}x{}@{} {} Mbps {:?}",
"NVENC D3D11 session: {}x{}@{} {}-bit{} {} Mbps {:?}",
self.width,
self.height,
self.fps,
self.bit_depth,
if self.hdr { " HDR(BT.2020 PQ)" } else { "" },
self.bitrate_bps / 1_000_000,
self.codec_guid
);
@@ -303,21 +347,36 @@ impl Encoder for NvencD3d11Encoder {
// The capturer recreates its D3D11 device on a desktop switch (secure/Winlogon) and may come
// back at a different resolution (user session applies its own mode on login). Re-init when the
// frame arrives on a different device OR at a different size than our session was built on.
// HDR (BT.2020 PQ 10-bit) when the capturer hands us a 10-bit R10G10B10A2 frame. This can flip
// mid-session when the user toggles HDR (which arrives as a capture device recreate anyway).
let hdr = matches!(captured.format, PixelFormat::Rgb10a2);
let dev_raw = frame.device.as_raw();
let size_changed = self.inited && (self.width != captured.width || self.height != captured.height);
if self.inited && (self.init_device != dev_raw || size_changed) {
let size_changed =
self.inited && (self.width != captured.width || self.height != captured.height);
let hdr_changed = self.inited && self.hdr != hdr;
if self.inited && (self.init_device != dev_raw || size_changed || hdr_changed) {
tracing::info!(
device_changed = self.init_device != dev_raw,
size_changed,
hdr_changed,
hdr,
new = format!("{}x{}", captured.width, captured.height),
"NVENC: capture device/size changed (desktop switch) — re-initializing session"
"NVENC: capture device/size/HDR changed — re-initializing session"
);
unsafe { self.teardown() };
}
if !self.inited {
// Adopt the current frame size so the encoder always matches what the capturer produces.
// Adopt the current frame size + colour so the encoder always matches the capturer output.
self.width = captured.width;
self.height = captured.height;
self.hdr = hdr;
if hdr {
// 10-bit BT.2020 PQ input; force Main10 regardless of the negotiated SDR bit depth.
self.bit_depth = 10;
self.buffer_fmt = nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ABGR10;
} else {
self.buffer_fmt = nv::NV_ENC_BUFFER_FORMAT::NV_ENC_BUFFER_FORMAT_ARGB;
}
let device = frame.device.clone();
self.init_session(&device)?;
self.init_device = dev_raw;
@@ -332,7 +391,8 @@ impl Encoder for NvencD3d11Encoder {
if !self.regs.contains_key(&key) {
let mut rr = nv::NV_ENC_REGISTER_RESOURCE {
version: nv::NV_ENC_REGISTER_RESOURCE_VER,
resourceType: nv::NV_ENC_INPUT_RESOURCE_TYPE::NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX,
resourceType:
nv::NV_ENC_INPUT_RESOURCE_TYPE::NV_ENC_INPUT_RESOURCE_TYPE_DIRECTX,
width: self.width,
height: self.height,
pitch: 0,
+5
View File
@@ -146,6 +146,11 @@ impl Encoder for OpenH264Encoder {
self.normalize_to_bgra(bytes, 3, false);
self.yuv.read_rgb(BgraSliceU8::new(&self.scratch, (w, h)));
}
// 10-bit HDR comes only from the GPU NVENC path; the software 8-bit H.264 encoder
// can't represent it (and never receives it — the capturer pairs Rgb10a2 with NVENC).
PixelFormat::Rgb10a2 => {
anyhow::bail!("software H.264 encoder cannot encode 10-bit HDR (Rgb10a2)")
}
}
if self.force_kf {