cat / goq
clone

turns a linux box into a private steam streaming appliance: plug in, tap your key, play from another computer

fix(streaming): resolve native dimensions per session

Dami committed 66f19fc
Keep unconfigured host dimensions immutable across Gamescope generations and bind adaptive resolution policy to the active encoder control dimensions. Add regression coverage for sequential native modes and unsupported adaptive tiers.
commit
66f19fc0d689306f10b2b8f7812132659035f617
parent
e2328beae680993a38c0f8a63c413114b77e831b
author
Dami <dami.etoile@gmail.com>
4 files changed, +124 -27
Mcrates/sigil-host/src/config.rs12----
Mcrates/sigil-host/src/main.rs9+--
Mcrates/sigil-host/src/server.rs37+++++++++++-
Mcrates/sigil-host/src/source.rs93++++++++++++++++++++++++++++--
M crates/sigil-host/src/config.rs +0 -12
M crates/sigil-host/src/main.rs +2 -7
@@ -758,7 +758,7 @@ impl Drop for CaptureTaskGuard {
758758 759759 async fn serve_command(args: ServeArgs) -> Result<()> {760760 let configured_service = args.config.is_some();761- let (mut config, loaded_config_revision, _lifecycle) = if let Some(config_path) = &args.config {761+ let (config, loaded_config_revision, _lifecycle) = if let Some(config_path) = &args.config {762762 let (loaded, lifecycle) =763763 config_management::prepare_service(config_path).map_err(anyhow::Error::new)?;764764 (loaded.config, Some(loaded.revision), lifecycle)
@@ -797,8 +797,7 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
797797 798798 let preflight_result: Result<(InputBackend, Option<PointerPositionTracker>)> = async {799799 let pointer_surface_dimensions = if config.source == VideoSource::TestPattern {800- let (width, height) = config.test_pattern_dimensions()?;801- config.apply_resolved_dimensions(width, height)?;800+ config.test_pattern_dimensions()?;802801 let status = tokio::process::Command::new(&config.ffmpeg_path)803802 .arg("-version")804803 .stdin(std::process::Stdio::null())
@@ -823,10 +822,6 @@ async fn serve_command(args: ServeArgs) -> Result<()> {
823822 encoded_framerate = preflight.video_mode.framerate,824823 "Gamescope PipeWire capture preflight passed"825824 );826- config.apply_resolved_dimensions(827- u32::from(preflight.video_mode.width),828- u32::from(preflight.video_mode.height),829- )?;830825 if config.audio.is_some() {831826 audio::preflight_audio_static(&config).await?;832827 info!("PipeWire Opus audio static preflight passed");
M crates/sigil-host/src/server.rs +35 -2
@@ -413,6 +413,13 @@ fn motion_resolution_policy(width: u32, height: u32) -> Result<Option<MotionReso
413413 }414414 }415415 416+fn motion_resolution_policy_for_encoder(417+ control: &EncoderControl,418+) -> Result<Option<MotionResolutionPolicy>> {419+ let (width, height) = control.initial_dimensions();420+ motion_resolution_policy(u32::from(width), u32::from(height))421+}422+416423 #[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]417424 struct FeedbackIngressCursor {418425 last_report_id: Option<u64>,
@@ -3235,8 +3242,12 @@ async fn serve_media_feedback(
32353242 let mut receiver_open = true;32363243 let mut controller = ShadowBitrateController::new(ceiling_kbps, lease.telemetry.snapshot());32373244 let mut resolution_controller = if encoder_actuation_available {3238- let (width, height) = config.resolved_dimensions()?;3239- motion_resolution_policy(width, height)?3245+ lease3246+ .encoder_control3247+ .as_ref()3248+ .map(motion_resolution_policy_for_encoder)3249+ .transpose()?3250+ .flatten()32403251 } else {32413252 None32423253 };
@@ -5197,6 +5208,28 @@ mod tests {
51975208 assert!(motion_resolution_policy(1_366, 768).unwrap().is_none());51985209 }51995210 5211+ #[test]5212+ fn feedback_resolution_policy_uses_encoder_generation_dimensions() {5213+ let full_hd = crate::source::EncoderControlTestHarness::with_dimensions(1_920, 1_080);5214+ let policy = motion_resolution_policy_for_encoder(&full_hd.control)5215+ .unwrap()5216+ .unwrap();5217+ assert_eq!(5218+ policy.native,5219+ VideoDimensions {5220+ width: 1_920,5221+ height: 1_080,5222+ }5223+ );5224+5225+ let no_exact_tier = crate::source::EncoderControlTestHarness::with_dimensions(1_366, 768);5226+ assert!(5227+ motion_resolution_policy_for_encoder(&no_exact_tier.control)5228+ .unwrap()5229+ .is_none()5230+ );5231+ }5232+52005233 #[test]52015234 fn motion_downshifts_immediately_and_recovers_only_after_settle_and_clean_windows() {52025235 let now = Instant::now();
M crates/sigil-host/src/source.rs +87 -6
@@ -76,6 +76,8 @@ pub struct EncoderControlStatus {
7676 /// the latest revision. Revisions are process-monotonic and never wrap.7777 #[derive(Clone, Debug)]7878 pub struct EncoderControl {79+ initial_width: u16,80+ initial_height: u16,7981 next_revision: Arc<AtomicU64>,8082 desired: watch::Sender<EncoderControlDesired>,8183 status: watch::Receiver<EncoderControlStatus>,
@@ -86,7 +88,10 @@ pub struct EncoderControl {
8688 expect(dead_code, reason = "constructed only by the opt-in encoder backend")8789 )]8890 impl EncoderControl {89- fn new() -> (91+ fn new(92+ initial_width: u16,93+ initial_height: u16,94+ ) -> (9095 Self,9196 watch::Receiver<EncoderControlDesired>,9297 watch::Sender<EncoderControlStatus>,
@@ -95,6 +100,8 @@ impl EncoderControl {
95100 let (status_tx, status) = watch::channel(EncoderControlStatus::default());96101 (97102 Self {103+ initial_width,104+ initial_height,98105 next_revision: Arc::new(AtomicU64::new(0)),99106 desired,100107 status,
@@ -104,6 +111,10 @@ impl EncoderControl {
104111 )105112 }106113 114+ pub(crate) fn initial_dimensions(&self) -> (u16, u16) {115+ (self.initial_width, self.initial_height)116+ }117+107118 fn next_revision(&self) -> Result<u64> {108119 self.next_revision109120 .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |revision| {
@@ -315,7 +326,11 @@ pub(crate) struct EncoderControlTestHarness {
315326 #[cfg(test)]316327 impl EncoderControlTestHarness {317328 pub(crate) fn new() -> Self {318- let (control, desired, status) = EncoderControl::new();329+ Self::with_dimensions(1_280, 800)330+ }331+332+ pub(crate) fn with_dimensions(width: u16, height: u16) -> Self {333+ let (control, desired, status) = EncoderControl::new(width, height);319334 Self {320335 control,321336 status,
@@ -608,7 +623,7 @@ fn spawn_in_process_pipeline(
608623 ) -> Result<EncodedSource> {609624 let (sender, receiver) = watch::channel(None);610625 let (current_gop_sender, current_gop) = watch::channel(None);611- let (control, desired, status) = EncoderControl::new();626+ let (control, desired, status) = EncoderControl::new(initial_width, initial_height);612627 let task = tokio::spawn(async move {613628 tokio::task::spawn_blocking(move || {614629 run_in_process_pipeline(
@@ -2130,9 +2145,44 @@ mod tests {
21302145 }21312146 }21322147 2148+ fn gamescope_dump(serial: u64, width: u16, height: u16) -> Vec<u8> {2149+ serde_json::to_vec(&serde_json::json!([{2150+ "type": "PipeWire:Interface:Node",2151+ "info": {2152+ "props": {2153+ "node.name": "gamescope",2154+ "media.class": "Video/Source",2155+ "device.bus-path": "pci-0000:04:00.0",2156+ "object.serial": serial,2157+ },2158+ "params": {2159+ "EnumFormat": [{2160+ "mediaType": "video",2161+ "mediaSubtype": "raw",2162+ "format": "NV12",2163+ "size": { "width": width, "height": height },2164+ }],2165+ },2166+ },2167+ }]))2168+ .unwrap()2169+ }2170+2171+ #[test]2172+ fn encoder_control_retains_initial_session_dimensions() {2173+ let (native, _desired, _status) = EncoderControl::new(2_560, 1_600);2174+ let (other, _desired, _status) = EncoderControl::new(1_366, 768);2175+2176+ assert_eq!(native.initial_dimensions(), (2_560, 1_600));2177+ assert_eq!(other.initial_dimensions(), (1_366, 768));2178+ native.request_resolution(1_280, 800).unwrap();2179+ assert_eq!(native.initial_dimensions(), (2_560, 1_600));2180+ assert_eq!(other.initial_dimensions(), (1_366, 768));2181+ }2182+21332183 #[tokio::test]21342184 async fn encoder_control_coalesces_latest_state_and_acknowledges_only_configured_idr() {2135- let (control, mut desired, status) = EncoderControl::new();2185+ let (control, mut desired, status) = EncoderControl::new(1_280, 800);21362186 21372187 let superseded_bitrate_revision = control.request_bitrate_kbps(4_000).unwrap();21382188 let bitrate_revision = control.request_bitrate_kbps(8_000).unwrap();
@@ -2227,7 +2277,7 @@ mod tests {
22272277 .is_err()22282278 );22292279 2230- let (closed_control, _desired, closed_status) = EncoderControl::new();2280+ let (closed_control, _desired, closed_status) = EncoderControl::new(1_280, 800);22312281 let closed_revision = closed_control.request_force_keyframe().unwrap();22322282 drop(closed_status);22332283 assert!(
@@ -2242,7 +2292,7 @@ mod tests {
22422292 22432293 #[tokio::test]22442294 async fn recovery_waiter_accepts_a_newer_configured_idr_revision() {2245- let (control, _desired, status) = EncoderControl::new();2295+ let (control, _desired, status) = EncoderControl::new(1_280, 800);22462296 let recovery_revision = control.request_force_keyframe().unwrap();22472297 let newer_revision = control.request_force_keyframe().unwrap();22482298 assert!(newer_revision > recovery_revision);
@@ -2756,6 +2806,37 @@ mod tests {
27562806 );27572807 }27582808 2809+ #[test]2810+ fn unconfigured_gamescope_mode_follows_each_resolved_session() {2811+ let mut config = gamescope_config();2812+ config.width = None;2813+ config.height = None;2814+2815+ for (serial, width, height) in [2816+ (731, 2_560, 1_600),2817+ (900, 1_280, 800),2818+ (901, 1_920, 1_080),2819+ (902, 1_366, 768),2820+ ] {2821+ let resolved =2822+ resolve_pipewire_node(&gamescope_dump(serial, width, height), &config).unwrap();2823+ assert_eq!(resolved.target_object, serial.to_string());2824+ assert_eq!(2825+ resolved.pointer_surface_dimensions,2826+ PointerSurfaceDimensions::new(width, height).unwrap()2827+ );2828+ assert_eq!(2829+ resolved.video_mode,2830+ CaptureVideoMode {2831+ width,2832+ height,2833+ framerate: 60,2834+ }2835+ );2836+ assert_eq!(config.configured_dimensions(), None);2837+ }2838+ }2839+27592840 #[test]27602841 fn resolves_pinned_upstream_gamescope_pipewire_contract() {27612842 // Sanitized from the standard interface emitted by Valve Gamescope