diff --git a/Cargo.lock b/Cargo.lock index 05e753bf..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?tag=v0.7.2#061d5ac05cb8d8257af4842228fc38b14bcb55a8" +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 997394c4..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", tag = "v0.7.2", 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" 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..b3c553a6 100644 --- a/moonshine-core/src/session/stream/video/mod.rs +++ b/moonshine-core/src/session/stream/video/mod.rs @@ -170,6 +170,10 @@ pub struct VideoStreamContext { /// Maximum number of reference frames for the video encoder. pub max_reference_frames: u32, + /// 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, } diff --git a/moonshine-core/src/session/stream/video/pipeline/mod.rs b/moonshine-core/src/session/stream/video/pipeline/mod.rs index f4e3cd77..3d9bb0b7 100644 --- a/moonshine-core/src/session/stream/video/pipeline/mod.rs +++ b/moonshine-core/src/session/stream/video/pipeline/mod.rs @@ -558,8 +558,8 @@ 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::bt709().with_full_range(ctx.full_range), + VideoDynamicRange::Hdr => ColorDescription::bt2020_pq().with_full_range(ctx.full_range), }; // Create encode configuration. @@ -689,8 +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::bt709(), - VideoDynamicRange::Hdr => 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() { @@ -780,6 +780,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 +924,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 +956,20 @@ 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::bt709().with_full_range(ctx.full_range), BT2408_SDR_REFERENCE_NITS, ), FrameColorSpace::Bt2020Pq => ( ColorSpace::Bt2020, - false, - ColorDescription::bt2020_pq(), + ctx.full_range, + ColorDescription::bt2020_pq().with_full_range(ctx.full_range), BT2408_SDR_REFERENCE_NITS, ), FrameColorSpace::ScrgbLinear => ( ColorSpace::Bt709LinearToBt2020Pq, - false, - ColorDescription::bt2020_pq(), + ctx.full_range, + ColorDescription::bt2020_pq().with_full_range(ctx.full_range), SCRGB_REFERENCE_WHITE_NITS, ), }; @@ -1010,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 == Some(ColorDescription::bt2020_pq()); + let hdr_enabled = encoder_color_desc.is_some_and(|desc| desc.is_hdr()); let new_state = HdrModeState { enabled: hdr_enabled, metadata: frame.hdr_metadata, @@ -1044,6 +1049,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, diff --git a/moonshine-tools/src/bin/bench.rs b/moonshine-tools/src/bin/bench.rs index 2160d1cd..8c71018f 100644 --- a/moonshine-tools/src/bin/bench.rs +++ b/moonshine-tools/src/bin/bench.rs @@ -597,6 +597,7 @@ async fn run_benchmark( }, chroma_sampling_type: VideoChromaSampling::Yuv420, max_reference_frames: 1, + full_range: false, encrypt_video: false, };