fix(encode/windows): the encode bit depth must follow the pixels, not the negotiation

All three Windows backends derived it the same wrong way —
`bit_depth >= 10 || matches!(format, P010 | Rgb10a2)` at ffmpeg_win.rs:154,
amf.rs:1275 and qsv.rs:777 — so the NEGOTIATED depth could force a 10-bit encoder
over an 8-bit capture.

That combination is not hypothetical. A client advertises 10-bit, the handshake
negotiates bit_depth=10, and then enabling advanced colour on the IDD virtual
display fails — at which point the capturer says exactly that and delivers 8-bit
NV12 anyway ("10-bit HDR was negotiated but enabling advanced color on the virtual
display FAILED — encoding 8-bit SDR"). Every vendor then lost the session, each in
its own way:

  - native AMF and native QSV derived `expected = P010`, saw Nv12, and bail!'d at
    open;
  - the libavcodec path accepted the open, built a P010 encoder, and then failed
    EVERY submit forever, because its per-frame check recomputes the depth from the
    frame and never matches. reset() could not help: the rebuild re-derived the
    same wrong depth from the same stored bit_depth. The host burned
    MAX_ENCODER_RESETS and ended the session.

The last one is the worst of the three because it IS the fallback: native QSV
correctly refuses this input at open, and lib.rs then falls back to the ffmpeg path
"for robustness" — which accepted it and died per frame instead.

Since the duplication was the bug, the fix is one shared `ten_bit_input()` in
codec.rs that all three call, and it follows the delivered pixels. That also keeps
the stream HONEST rather than merely alive: the depth selects the colour signalling
(BT.2020 PQ vs BT.709) and the staging surface format, so an 8-bit capture now
produces an 8-bit stream that says it is SDR — which is what the capturer already
reported it was sending. It warns when the negotiated depth is discarded.

The negotiated depth stays an upper bound. The session LABEL may still claim HDR;
that mismatch lives in the negotiation, not in the encoder, and is not addressed
here.

`is_10bit_format` keeps its (now format-only) definition and is used by
submit_d3d11's per-frame check, so the predicate the encoder was built from and the
one it re-checks per frame cannot drift. That check can now only fire on a genuine
mid-stream depth change.

Verified `--all-targets -D warnings` on Windows (no features / pyrowave / qsv /
nvenc,qsv; 31 tests) and Linux (default; shipped nvenc+vulkan-encode+pyrowave).

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-25 00:32:37 +02:00
co-authored by Claude Opus 5
parent 6c97c00add
commit 6be174dc9c
4 changed files with 64 additions and 7 deletions
+14 -5
View File
@@ -151,8 +151,14 @@ fn sws_src(format: PixelFormat) -> Result<Pixel> {
}
/// Does this captured format imply a 10-bit encode (P010 / Rgb10a2)?
fn is_10bit_format(format: PixelFormat, bit_depth: u8) -> bool {
bit_depth >= 10 || matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
///
/// Depth follows the PIXELS, not the negotiated `bit_depth` — see
/// [`crate::ten_bit_input`] for why, and for the failure this shape used to produce here in
/// particular: a 10-bit-negotiated session over an 8-bit capture built a P010 encoder whose every
/// `submit_d3d11` then failed the depth check below, forever, with `reset()` unable to help
/// because the rebuild re-derived the same wrong answer.
fn is_10bit_format(format: PixelFormat) -> bool {
matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2)
}
/// Build the FFmpeg encoder context shared by both inner paths: name, mode, low-latency RC,
@@ -350,7 +356,7 @@ impl SystemInner {
bitrate_bps: u64,
bit_depth: u8,
) -> Result<Self> {
let ten_bit = is_10bit_format(format, bit_depth);
let ten_bit = crate::ten_bit_input(format, bit_depth);
let sw_av = if ten_bit {
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
} else {
@@ -472,7 +478,10 @@ impl SystemInner {
pts: i64,
idr: bool,
) -> Result<()> {
let fmt_10 = matches!(format, PixelFormat::P010 | PixelFormat::Rgb10a2);
// Same predicate the encoder was built from (`ten_bit_input`), so this can only fire on a
// genuine MID-STREAM depth change — never, as it used to, on every frame of a session that
// merely negotiated 10-bit over an 8-bit capture.
let fmt_10 = is_10bit_format(format);
anyhow::ensure!(
fmt_10 == self.ten_bit,
"captured format {format:?} bit-depth changed under the encoder (built {}-bit)",
@@ -851,7 +860,7 @@ impl ZeroCopyInner {
bit_depth: u8,
device: &ID3D11Device,
) -> Result<Self> {
let ten_bit = is_10bit_format(format, bit_depth);
let ten_bit = crate::ten_bit_input(format, bit_depth);
let sw_av = if ten_bit {
ffi::AVPixelFormat::AV_PIX_FMT_P010LE
} else {