From 02d576cbe816b5abf3baa5fb5d9e322b2d2de23a Mon Sep 17 00:00:00 2001 From: lutyjj <10267813+lutyjj@users.noreply.github.com> Date: Wed, 29 Jul 2026 15:39:06 +0000 Subject: [PATCH 1/5] fix(video): honour the client's requested color range The color converter and the encoder VUI both hardcoded limited-range luma, and `x-nv-video[0].encoderCscMode` was never read. A client that requests full range decodes by expanding 16-235 to 0-255, so limited-range data arrives with black lifted to 16 and white capped below 255. Take bit 0 of the attribute as the range request, matching Sunshine's `colorspace_from_client_config`, and thread it to both the converter's quantisation and the VUI so the data and the signal agree. Decide the HDR mode sent over the control stream from the transfer function rather than by comparing the whole `ColorDescription` against `bt2020_pq()`, whose `full_range` is always false. The luma range is independent of whether a stream is HDR, so the struct comparison reported SDR for every full-range HDR session and the client never left SDR. The key-frame SEI gate asks the same question and is deliberately left as a struct comparison: correcting it newly enables AV1 metadata injection for full-range clients, and that path produces streams the client cannot decode. It is tracked separately. The remaining bits of the field select an SDR colorspace. Only Rec.709 is encoded. Rec.601 doubles as the protocol default for clients that never chose one, so it logs quietly; an explicit request for anything else is warned about rather than silently mis-encoded. --- moonshine-core/src/rtsp.rs | 27 ++++++ .../src/session/stream/video/mod.rs | 4 + .../src/session/stream/video/pipeline/mod.rs | 83 +++++++++++++++---- moonshine-tools/src/bin/bench.rs | 1 + 4 files changed, 100 insertions(+), 15 deletions(-) diff --git a/moonshine-core/src/rtsp.rs b/moonshine-core/src/rtsp.rs index 036dffbf..bf21edba 100644 --- a/moonshine-core/src/rtsp.rs +++ b/moonshine-core/src/rtsp.rs @@ -360,6 +360,32 @@ impl RtspServer { let max_reference_frames: u32 = get_optional_sdp_attribute(&sdp_session, "x-nv-video[0].maxNumReferenceFrames").unwrap_or(1); + // Bit 0 selects full-range luma, the remaining bits an SDR colorspace. + // Sunshine: `colorspace_from_client_config()` + const CSC_COLORSPACE_REC601: u32 = 0; + const CSC_COLORSPACE_REC709: u32 = 1; + let encoder_csc_mode: Option = get_optional_sdp_attribute(&sdp_session, "x-nv-video[0].encoderCscMode"); + let full_range = encoder_csc_mode.unwrap_or_default() & 0x1 != 0; + + // Only Rec.709 is encoded. Rec.601 doubles as the protocol default for + // clients that never chose a colorspace, so it stays quiet; higher + // values are explicit requests worth a warning. + match encoder_csc_mode.map(|mode| mode >> 1) { + Some(CSC_COLORSPACE_REC601) => { + tracing::debug!("Client sent SDR colorspace Rec.601 (the protocol default), encoding Rec.709."); + }, + Some(colorspace) if colorspace != CSC_COLORSPACE_REC709 => { + tracing::warn!( + "Client requested SDR colorspace {colorspace} via encoderCscMode, encoding Rec.709 instead." + ); + }, + _ => {}, + } + tracing::info!( + "Client requested {} range video.", + if full_range { "full" } else { "limited" } + ); + // Parse the client's encryption flags from the ANNOUNCE SDP. let client_encryption_flags: u8 = get_optional_sdp_attribute(&sdp_session, "x-ss-general.encryptionEnabled").unwrap_or(0); @@ -376,6 +402,7 @@ impl RtspServer { dynamic_range, chroma_sampling_type, max_reference_frames, + full_range, encrypt_video: self.video_config.encrypt && (client_encryption_flags & EncryptionFlags::Video as u8 != 0), }; diff --git a/moonshine-core/src/session/stream/video/mod.rs b/moonshine-core/src/session/stream/video/mod.rs index 7e5d9083..42a81da6 100644 --- a/moonshine-core/src/session/stream/video/mod.rs +++ b/moonshine-core/src/session/stream/video/mod.rs @@ -172,6 +172,10 @@ pub struct VideoStreamContext { /// Whether the client has enabled video encryption. pub encrypt_video: bool, + + /// Whether the client asked for full-range (0-255) rather than + /// limited-range (16-235) luma. + pub full_range: bool, } /// Handle returned by `VideoStream::start` that gates the pipeline and packet handler. diff --git a/moonshine-core/src/session/stream/video/pipeline/mod.rs b/moonshine-core/src/session/stream/video/pipeline/mod.rs index f4e3cd77..a1cd4329 100644 --- a/moonshine-core/src/session/stream/video/pipeline/mod.rs +++ b/moonshine-core/src/session/stream/video/pipeline/mod.rs @@ -465,6 +465,12 @@ struct VideoPipelineInner { keys_rx: SessionKeysReceiver, } +/// Whether a color description carries the PQ transfer function, which is what +/// makes the stream HDR. The luma range is independent of it. +fn is_hdr(desc: ColorDescription) -> bool { + desc.transfer_characteristics == ColorDescription::bt2020_pq().transfer_characteristics +} + impl VideoPipelineInner { #[allow(clippy::too_many_arguments)] fn run( @@ -558,8 +564,14 @@ impl VideoPipelineInner { // Select color description for VUI signaling. let color_description = match ctx.dynamic_range { - VideoDynamicRange::Sdr => ColorDescription::bt709(), - VideoDynamicRange::Hdr => ColorDescription::bt2020_pq(), + VideoDynamicRange::Sdr => ColorDescription { + full_range: ctx.full_range, + ..ColorDescription::bt709() + }, + VideoDynamicRange::Hdr => ColorDescription { + full_range: ctx.full_range, + ..ColorDescription::bt2020_pq() + }, }; // Create encode configuration. @@ -689,8 +701,14 @@ impl VideoPipelineInner { // color_desc differs, we call set_color_description() to update // the SPS/sequence header. let mut encoder_color_desc: Option = Some(match ctx.dynamic_range { - VideoDynamicRange::Sdr => ColorDescription::bt709(), - VideoDynamicRange::Hdr => ColorDescription::bt2020_pq(), + VideoDynamicRange::Sdr => ColorDescription { + full_range: ctx.full_range, + ..ColorDescription::bt709() + }, + VideoDynamicRange::Hdr => ColorDescription { + full_range: ctx.full_range, + ..ColorDescription::bt2020_pq() + }, }); while !stop_session_manager.is_shutdown_triggered() { @@ -780,6 +798,11 @@ impl VideoPipelineInner { match encoder.encode(encoder.input_image()) { Ok(future) => { let submitted_at = std::time::Instant::now(); + // Deliberately still a whole-struct comparison, unlike the + // control-stream check above. Correcting it newly enables + // AV1 metadata injection for full-range clients, and that + // produces streams Moonlight cannot decode. Left until the + // AV1 path is fixed. let inject_hdr = encoder_color_desc == Some(ColorDescription::bt2020_pq()); let frame_context = FrameContext { created_at: now, @@ -919,8 +942,8 @@ impl VideoPipelineInner { Some(conv) => conv, None => { let (color_space, full_range) = match ctx.dynamic_range { - VideoDynamicRange::Sdr => (ColorSpace::Bt709, false), - VideoDynamicRange::Hdr => (ColorSpace::Bt2020, false), + VideoDynamicRange::Sdr => (ColorSpace::Bt709, ctx.full_range), + VideoDynamicRange::Hdr => (ColorSpace::Bt2020, ctx.full_range), }; let mut config = ColorConverterConfig::new(ctx.width, ctx.height, frame_input_format, output_format); @@ -951,20 +974,29 @@ impl VideoPipelineInner { let (cs, full_range, color_desc, sdr_white_nits) = match frame_cs { FrameColorSpace::Srgb => ( ColorSpace::Bt709, - false, - ColorDescription::bt709(), + ctx.full_range, + ColorDescription { + full_range: ctx.full_range, + ..ColorDescription::bt709() + }, BT2408_SDR_REFERENCE_NITS, ), FrameColorSpace::Bt2020Pq => ( ColorSpace::Bt2020, - false, - ColorDescription::bt2020_pq(), + ctx.full_range, + ColorDescription { + full_range: ctx.full_range, + ..ColorDescription::bt2020_pq() + }, BT2408_SDR_REFERENCE_NITS, ), FrameColorSpace::ScrgbLinear => ( ColorSpace::Bt709LinearToBt2020Pq, - false, - ColorDescription::bt2020_pq(), + ctx.full_range, + ColorDescription { + full_range: ctx.full_range, + ..ColorDescription::bt2020_pq() + }, SCRGB_REFERENCE_WHITE_NITS, ), }; @@ -1010,7 +1042,7 @@ impl VideoPipelineInner { // In HDR sessions, `enabled` reflects whether the current // frame is encoded as BT.2020+PQ (true) or BT.709 (false). if ctx.dynamic_range == VideoDynamicRange::Hdr { - let hdr_enabled = encoder_color_desc == Some(ColorDescription::bt2020_pq()); + let hdr_enabled = encoder_color_desc.is_some_and(is_hdr); let new_state = HdrModeState { enabled: hdr_enabled, metadata: frame.hdr_metadata, @@ -1044,6 +1076,11 @@ impl VideoPipelineInner { // Hand this frame's context plus its packet future to the // consumer thread, which awaits the future, injects HDR SEI if // needed, packetizes and sends it, and records stats. + // Deliberately still a whole-struct comparison, unlike the + // control-stream check above. Correcting it newly enables + // AV1 metadata injection for full-range clients, and that + // produces streams Moonlight cannot decode. Left until the + // AV1 path is fixed. let inject_hdr = encoder_color_desc == Some(ColorDescription::bt2020_pq()); let frame_context = FrameContext { created_at: frame.created_at, @@ -1101,9 +1138,25 @@ impl VideoPipelineInner { #[cfg(test)] mod tests { - use super::{BT2408_SDR_REFERENCE_NITS, SCRGB_REFERENCE_WHITE_NITS, drm_fourcc_to_input, is_device_lost}; + use super::{BT2408_SDR_REFERENCE_NITS, SCRGB_REFERENCE_WHITE_NITS, drm_fourcc_to_input, is_device_lost, is_hdr}; use ash::vk; - use pixelforge::{InputFormat, PixelForgeError}; + use pixelforge::{ColorDescription, InputFormat, PixelForgeError}; + + /// The client's requested luma range must not change whether the stream + /// counts as HDR. + #[test] + fn hdr_is_decided_by_the_transfer_function_not_the_range() { + for full_range in [false, true] { + assert!(is_hdr(ColorDescription { + full_range, + ..ColorDescription::bt2020_pq() + })); + assert!(!is_hdr(ColorDescription { + full_range, + ..ColorDescription::bt709() + })); + } + } // DRM fourccs — kept in the test module to document the byte ordering // explicitly and guard against accidental typos in the production match. diff --git a/moonshine-tools/src/bin/bench.rs b/moonshine-tools/src/bin/bench.rs index 2160d1cd..7a489cb2 100644 --- a/moonshine-tools/src/bin/bench.rs +++ b/moonshine-tools/src/bin/bench.rs @@ -598,6 +598,7 @@ async fn run_benchmark( chroma_sampling_type: VideoChromaSampling::Yuv420, max_reference_frames: 1, encrypt_video: false, + full_range: false, }; let audio_ctx = AudioStreamContext { From 47d3b4bb34321a1db8bc5fb272a41ab60f613e5b Mon Sep 17 00:00:00 2001 From: lutyjj <10267813+lutyjj@users.noreply.github.com> Date: Fri, 31 Jul 2026 21:47:03 +0000 Subject: [PATCH 2/5] style(video): declare full_range in the same order it is built --- moonshine-core/src/session/stream/video/mod.rs | 6 +++--- moonshine-tools/src/bin/bench.rs | 2 +- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/moonshine-core/src/session/stream/video/mod.rs b/moonshine-core/src/session/stream/video/mod.rs index 42a81da6..b3c553a6 100644 --- a/moonshine-core/src/session/stream/video/mod.rs +++ b/moonshine-core/src/session/stream/video/mod.rs @@ -170,12 +170,12 @@ pub struct VideoStreamContext { /// Maximum number of reference frames for the video encoder. pub max_reference_frames: u32, - /// Whether the client has enabled video encryption. - pub encrypt_video: bool, - /// Whether the client asked for full-range (0-255) rather than /// limited-range (16-235) luma. pub full_range: bool, + + /// Whether the client has enabled video encryption. + pub encrypt_video: bool, } /// Handle returned by `VideoStream::start` that gates the pipeline and packet handler. diff --git a/moonshine-tools/src/bin/bench.rs b/moonshine-tools/src/bin/bench.rs index 7a489cb2..8c71018f 100644 --- a/moonshine-tools/src/bin/bench.rs +++ b/moonshine-tools/src/bin/bench.rs @@ -597,8 +597,8 @@ async fn run_benchmark( }, chroma_sampling_type: VideoChromaSampling::Yuv420, max_reference_frames: 1, - encrypt_video: false, full_range: false, + encrypt_video: false, }; let audio_ctx = AudioStreamContext { From 69024017e2d430abb673d63393a48ecd179f9968 Mon Sep 17 00:00:00 2001 From: lutyjj <10267813+lutyjj@users.noreply.github.com> Date: Sat, 1 Aug 2026 17:21:59 +0000 Subject: [PATCH 3/5] refactor(video): use pixelforge ColorDescription builder and is_hdr with_full_range replaces struct-update syntax at seven sites and the open-coded transfer-function check moves onto the type, as requested in review. Needs a pixelforge pin at or past the commit that added the API (pixelforge PR 30). --- .../src/session/stream/video/pipeline/mod.rs | 63 +++---------------- 1 file changed, 10 insertions(+), 53 deletions(-) diff --git a/moonshine-core/src/session/stream/video/pipeline/mod.rs b/moonshine-core/src/session/stream/video/pipeline/mod.rs index a1cd4329..3d9bb0b7 100644 --- a/moonshine-core/src/session/stream/video/pipeline/mod.rs +++ b/moonshine-core/src/session/stream/video/pipeline/mod.rs @@ -465,12 +465,6 @@ struct VideoPipelineInner { keys_rx: SessionKeysReceiver, } -/// Whether a color description carries the PQ transfer function, which is what -/// makes the stream HDR. The luma range is independent of it. -fn is_hdr(desc: ColorDescription) -> bool { - desc.transfer_characteristics == ColorDescription::bt2020_pq().transfer_characteristics -} - impl VideoPipelineInner { #[allow(clippy::too_many_arguments)] fn run( @@ -564,14 +558,8 @@ impl VideoPipelineInner { // Select color description for VUI signaling. let color_description = match ctx.dynamic_range { - VideoDynamicRange::Sdr => ColorDescription { - full_range: ctx.full_range, - ..ColorDescription::bt709() - }, - VideoDynamicRange::Hdr => ColorDescription { - full_range: ctx.full_range, - ..ColorDescription::bt2020_pq() - }, + VideoDynamicRange::Sdr => ColorDescription::bt709().with_full_range(ctx.full_range), + VideoDynamicRange::Hdr => ColorDescription::bt2020_pq().with_full_range(ctx.full_range), }; // Create encode configuration. @@ -701,14 +689,8 @@ impl VideoPipelineInner { // color_desc differs, we call set_color_description() to update // the SPS/sequence header. let mut encoder_color_desc: Option = Some(match ctx.dynamic_range { - VideoDynamicRange::Sdr => ColorDescription { - full_range: ctx.full_range, - ..ColorDescription::bt709() - }, - VideoDynamicRange::Hdr => ColorDescription { - full_range: ctx.full_range, - ..ColorDescription::bt2020_pq() - }, + VideoDynamicRange::Sdr => ColorDescription::bt709().with_full_range(ctx.full_range), + VideoDynamicRange::Hdr => ColorDescription::bt2020_pq().with_full_range(ctx.full_range), }); while !stop_session_manager.is_shutdown_triggered() { @@ -975,28 +957,19 @@ impl VideoPipelineInner { FrameColorSpace::Srgb => ( ColorSpace::Bt709, ctx.full_range, - ColorDescription { - full_range: ctx.full_range, - ..ColorDescription::bt709() - }, + ColorDescription::bt709().with_full_range(ctx.full_range), BT2408_SDR_REFERENCE_NITS, ), FrameColorSpace::Bt2020Pq => ( ColorSpace::Bt2020, ctx.full_range, - ColorDescription { - full_range: ctx.full_range, - ..ColorDescription::bt2020_pq() - }, + ColorDescription::bt2020_pq().with_full_range(ctx.full_range), BT2408_SDR_REFERENCE_NITS, ), FrameColorSpace::ScrgbLinear => ( ColorSpace::Bt709LinearToBt2020Pq, ctx.full_range, - ColorDescription { - full_range: ctx.full_range, - ..ColorDescription::bt2020_pq() - }, + ColorDescription::bt2020_pq().with_full_range(ctx.full_range), SCRGB_REFERENCE_WHITE_NITS, ), }; @@ -1042,7 +1015,7 @@ impl VideoPipelineInner { // In HDR sessions, `enabled` reflects whether the current // frame is encoded as BT.2020+PQ (true) or BT.709 (false). if ctx.dynamic_range == VideoDynamicRange::Hdr { - let hdr_enabled = encoder_color_desc.is_some_and(is_hdr); + let hdr_enabled = encoder_color_desc.is_some_and(|desc| desc.is_hdr()); let new_state = HdrModeState { enabled: hdr_enabled, metadata: frame.hdr_metadata, @@ -1138,25 +1111,9 @@ impl VideoPipelineInner { #[cfg(test)] mod tests { - use super::{BT2408_SDR_REFERENCE_NITS, SCRGB_REFERENCE_WHITE_NITS, drm_fourcc_to_input, is_device_lost, is_hdr}; + use super::{BT2408_SDR_REFERENCE_NITS, SCRGB_REFERENCE_WHITE_NITS, drm_fourcc_to_input, is_device_lost}; use ash::vk; - use pixelforge::{ColorDescription, InputFormat, PixelForgeError}; - - /// The client's requested luma range must not change whether the stream - /// counts as HDR. - #[test] - fn hdr_is_decided_by_the_transfer_function_not_the_range() { - for full_range in [false, true] { - assert!(is_hdr(ColorDescription { - full_range, - ..ColorDescription::bt2020_pq() - })); - assert!(!is_hdr(ColorDescription { - full_range, - ..ColorDescription::bt709() - })); - } - } + use pixelforge::{InputFormat, PixelForgeError}; // DRM fourccs — kept in the test module to document the byte ordering // explicitly and guard against accidental typos in the production match. From a8e4e0c8bda7bc09f55ff275083c1191f6ac6e48 Mon Sep 17 00:00:00 2001 From: lutyjj <10267813+lutyjj@users.noreply.github.com> Date: Sun, 2 Aug 2026 12:05:34 +0000 Subject: [PATCH 4/5] chore: update pixelforge for the ColorDescription API No tag past v0.7.2 yet, so pin the merge commit; swap for a tag once one is cut. --- Cargo.lock | 2 +- moonshine-core/Cargo.toml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 05e753bf..b3447622 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2847,7 +2847,7 @@ dependencies = [ [[package]] name = "pixelforge" version = "0.7.2" -source = "git+https://github.com/hgaiser/pixelforge?tag=v0.7.2#061d5ac05cb8d8257af4842228fc38b14bcb55a8" +source = "git+https://github.com/hgaiser/pixelforge?rev=9d9934c44077c89ec56f1d2a6667b05ff563fdc4#9d9934c44077c89ec56f1d2a6667b05ff563fdc4" dependencies = [ "ash", "futures-channel", diff --git a/moonshine-core/Cargo.toml b/moonshine-core/Cargo.toml index 997394c4..e97d8115 100644 --- a/moonshine-core/Cargo.toml +++ b/moonshine-core/Cargo.toml @@ -36,7 +36,7 @@ notify-rust = "4.17.0" open = "5.3.5" opus = "0.3.1" pkcs8 = "0.11.0" -pixelforge = { git = "https://github.com/hgaiser/pixelforge", tag = "v0.7.2", features = ["dmabuf"] } +pixelforge = { git = "https://github.com/hgaiser/pixelforge", rev = "9d9934c44077c89ec56f1d2a6667b05ff563fdc4", features = ["dmabuf"] } pulseaudio = "0.3.1" rcgen = "0.14.8" rsa = "0.9.10" From 6274fe77f63bd5848ceeb0cfec46b5f3668a9c70 Mon Sep 17 00:00:00 2001 From: Hans Gaiser Date: Sun, 2 Aug 2026 22:00:30 +0200 Subject: [PATCH 5/5] chore: update pixelforge to v0.8.0 --- Cargo.lock | 4 ++-- moonshine-core/Cargo.toml | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index b3447622..1118f3bf 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2846,8 +2846,8 @@ dependencies = [ [[package]] name = "pixelforge" -version = "0.7.2" -source = "git+https://github.com/hgaiser/pixelforge?rev=9d9934c44077c89ec56f1d2a6667b05ff563fdc4#9d9934c44077c89ec56f1d2a6667b05ff563fdc4" +version = "0.8.0" +source = "git+https://github.com/hgaiser/pixelforge?tag=v0.8.0#1767cfc036ca197871f326f4c8e0e71e4b170726" dependencies = [ "ash", "futures-channel", diff --git a/moonshine-core/Cargo.toml b/moonshine-core/Cargo.toml index e97d8115..9904f20e 100644 --- a/moonshine-core/Cargo.toml +++ b/moonshine-core/Cargo.toml @@ -36,7 +36,7 @@ notify-rust = "4.17.0" open = "5.3.5" opus = "0.3.1" pkcs8 = "0.11.0" -pixelforge = { git = "https://github.com/hgaiser/pixelforge", rev = "9d9934c44077c89ec56f1d2a6667b05ff563fdc4", features = ["dmabuf"] } +pixelforge = { git = "https://github.com/hgaiser/pixelforge", tag = "v0.8.0", features = ["dmabuf"] } pulseaudio = "0.3.1" rcgen = "0.14.8" rsa = "0.9.10"