fix(pyrowave): signal ycbcr_range=LIMITED in the sequence header
docker / deploy-docs (push) Successful in 23s
rpm / build-publish (44, fedora-44, punktfunk-fedora44-rpm) (push) Successful in 22m2s
rpm / build-publish (43, bazzite, punktfunk-fedora-rpm) (push) Successful in 22m8s
apple / swift (push) Successful in 1m23s
apple / screenshots (push) Successful in 6m20s
ci / docs-site (push) Successful in 1m10s
ci / web (push) Successful in 1m35s
android / android (push) Successful in 13m8s
windows-host / package (push) Successful in 16m16s
ci / bench (push) Successful in 5m21s
decky / build-publish (push) Successful in 20s
docker / build-push (--build-arg FEDORA_VERSION=44, ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora44-rpm) (push) Successful in 10s
docker / build-push (., web/Dockerfile, punktfunk-web) (push) Successful in 11s
docker / build-push (ci, ci/fedora-rpm.Dockerfile, punktfunk-fedora-rpm) (push) Successful in 9s
docker / build-push (ci, ci/rust-ci-noble.Dockerfile, punktfunk-rust-ci-noble) (push) Successful in 5m28s
docker / build-push (ci, ci/rust-ci.Dockerfile, punktfunk-rust-ci) (push) Successful in 10s
arch / build-publish (push) Successful in 18m52s
docker / build-push (docs-site, docs-site/Dockerfile, punktfunk-docs) (push) Successful in 12s
deb / build-publish-host (push) Successful in 9m38s
deb / build-publish (push) Successful in 12m35s
ci / rust (push) Successful in 19m12s

pyrowave's encoder fills the BitstreamSequenceHeader with `= {}` and its C API
offers no way to set colour/range, so it signals ycbcr_range=0=FULL — but both
host CSCs (rgb2yuv.comp on Linux, BgraToYuvPlanes on Windows) always emit BT.709
LIMITED Y'CbCr (black = Y'16). A client that honours the VUI (the Apple wavelet
decoder reads bit 30 of word1) then skips the limited→full expansion and shows
washed-out, raised blacks — reported on both Linux and Windows hosts.

Patch the range bit HONEST (mark_limited_range in the shared pyrowave_wire, called
by both encoders after packetize). Clients that hardcode limited (the Vulkan
video_pyrowave path) are unaffected, and pyrowave's own decode ignores the flag
(raw Y'CbCr reconstruction). No client rebuild needed. Unit-tested + asserted in
pyrowave_win_smoke; the smoke decode still round-trips 100/180/60.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-07-18 11:20:27 +02:00
parent ebd9967547
commit 574e3e4e3f
3 changed files with 51 additions and 0 deletions
@@ -1094,6 +1094,11 @@ impl PyroWaveEncoder {
"packetize",
)?;
packets.truncate(out_n.max(1));
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
// emits BT.709 LIMITED — patch the bit HONEST so VUI-honoring clients don't wash out blacks.
if let Some(p) = packets.first() {
crate::pyrowave_wire::mark_limited_range(&mut self.bitstream, p.offset);
}
// Frame into the wire AU via the shared helper (byte-identical on Linux + Windows): the dense
// single packet, or the datagram-aligned windowed AU (§4.4).
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
+33
View File
@@ -26,6 +26,25 @@ pub(crate) fn packet_boundary(wire_chunk: Option<usize>, dense_cap: usize) -> us
wire_chunk.map(|c| c - WINDOW_PREFIX).unwrap_or(dense_cap)
}
/// Patch the frame's `BitstreamSequenceHeader` to signal `ycbcr_range = LIMITED`. pyrowave's C API
/// fills the header with `= {}` (all VUI fields zeroed) and offers NO way to set colour/range, so it
/// signals `ycbcr_range = 0 = YCBCR_RANGE_FULL` — but BOTH host CSCs (`rgb2yuv.comp` on Linux, the
/// D3D11 `BgraToYuvPlanes` on Windows) always emit BT.709 **LIMITED** YCbCr (black = Y16). A client
/// that honours the VUI (the Apple wavelet decoder reads `(word1 >> 30) & 1`) then skips the
/// limited→full expansion and shows washed-out, raised blacks. Patching the bit makes the bitstream
/// HONEST for every client — clients that hardcode limited (the Vulkan `video_pyrowave` path) are
/// unaffected, and pyrowave's own decode ignores the flag (it reconstructs raw YCbCr). The other
/// zeroed VUI fields (BT.709 primaries / transform / transfer) are already correct.
///
/// `seq_offset` is the byte offset of the frame's 8-byte `BitstreamSequenceHeader` in `bitstream` —
/// the SOF packet's offset. `ycbcr_range` is bit 30 of the little-endian second word, i.e. bit 6 of
/// byte `seq_offset + 7` (`0x40`).
pub(crate) fn mark_limited_range(bitstream: &mut [u8], seq_offset: usize) {
if let Some(b) = bitstream.get_mut(seq_offset + 7) {
*b |= 0x40;
}
}
/// Frame pyrowave's `packets` (each an `(offset, size)` into `bitstream`) into the wire AU.
/// `wire_chunk = None` copies the single dense packet; `Some(chunk)` produces the windowed
/// datagram-aligned AU (a whole number of `chunk`-sized windows).
@@ -161,4 +180,18 @@ mod tests {
assert_eq!(packet_boundary(Some(1408), 999_999), 1404);
assert_eq!(packet_boundary(None, 777), 777);
}
#[test]
fn mark_limited_range_sets_only_the_range_bit() {
let mut bs = vec![0u8; 16];
mark_limited_range(&mut bs, 0);
// ycbcr_range = bit 30 of the LE second word = bit 6 of byte 7 (0x40); nothing else touched.
assert_eq!(bs[7], 0x40);
assert!(bs[..7].iter().all(|&b| b == 0));
assert!(bs[8..].iter().all(|&b| b == 0));
// Idempotent; an out-of-range offset is a silent no-op (never panics).
mark_limited_range(&mut bs, 0);
assert_eq!(bs[7], 0x40);
mark_limited_range(&mut bs, 100);
}
}
@@ -478,6 +478,11 @@ impl PyroWaveEncoder {
"packetize",
)?;
packets.truncate(out_n.max(1));
// Correct pyrowave's zeroed sequence-header VUI: it signals ycbcr_range=FULL, but our CSC
// emits BT.709 LIMITED — patch the bit HONEST so VUI-honoring clients don't wash out blacks.
if let Some(p) = packets.first() {
pyrowave_wire::mark_limited_range(&mut self.bitstream, p.offset);
}
let pkts: Vec<(usize, usize)> = packets.iter().map(|p| (p.offset, p.size)).collect();
let au = pyrowave_wire::build_au(&pkts, &self.bitstream, self.wire_chunk);
self.pending.push_back(EncodedFrame {
@@ -787,6 +792,14 @@ mod tests {
let au = enc.poll().expect("poll").expect("one AU per frame");
assert!(au.keyframe, "every pyrowave AU is a keyframe");
assert!(!au.data.is_empty(), "AU is non-empty");
// The dense AU starts with the 8-byte BitstreamSequenceHeader; the range VUI must read
// LIMITED (bit 30 = byte 7 bit 6 = 0x40) — `mark_limited_range` corrects pyrowave's zeroed
// default so VUI-honoring clients (Apple) don't wash out blacks.
assert_eq!(
au.data[7] & 0x40,
0x40,
"sequence header must signal ycbcr_range=LIMITED"
);
decode_plane_means(w, h, &au.data)
}