refactor(source): split capture pipeline into modules
- commit
- 10aa184f10ed2ffe7513c3bdad947d2a15291c18
- parent
- 404b41529394701f5fa02708ab284660cbe219e2
- parent
- ffbec3d483a69f53d73bae21d0a5c1ec06393c58
- author
- FelineStateMachine <dami.etoile@gmail.com>
| D | crates/ | 2972 | ------------------------------ |
| A | crates/ | 74 | + |
| A | crates/ | 355 | ++++ |
| A | crates/ | 1197 | ++++++++++++ |
| A | crates/ | 1156 | ++++++++++++ |
| A | crates/ | 285 | +++ |
@@ -1,2972 +0,0 @@
1-use std::borrow::Cow;2-use std::ffi::OsString;3-use std::fs;4-use std::process::{ExitStatus, Stdio};5-use std::sync::Arc;6-use std::sync::atomic::{AtomicU64, Ordering};7-use std::time::{Duration, Instant};8-9-use anyhow::{Context, Result, bail, ensure};10-use serde_json::Value;11-use sigil_protocol::PointerSurfaceDimensions;12-use tokio::io::{AsyncRead, AsyncReadExt};13-use tokio::process::Command;14-use tokio::sync::watch;15-use tracing::{debug, warn};16-17-use crate::clock::SessionClock;18-use crate::config::{19- GamescopeEncoderBackend, GamescopePipewireConfig, HostConfig, VaapiRateControl,20-};21-22-23-use gstreamer::prelude::ObjectExt;24-25-26-use std::os::unix::fs::{FileTypeExt, PermissionsExt};27-28-const MAX_ENCODE_BUFFER: usize = 16 * 1024 * 1024;29-const MAX_CURRENT_GOP_BYTES: usize = 32 * 1024 * 1024;30-const MAX_PW_DUMP_OUTPUT: usize = 8 * 1024 * 1024;31-const MAX_INSPECT_OUTPUT: usize = 1024 * 1024;32-const MAX_DIAGNOSTIC_OUTPUT: usize = 512 * 1024;33-const PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(5);34-const MAX_ENCODER_BITRATE_KBPS: u32 = 100_000;35-36-const RESOLUTION_APPLY_TIMEOUT: Duration = Duration::from_secs(2);37-38-39-struct RevisedBitrate {40- revision: u64,41- kbps: u32,42-}43-44-45-struct RevisedResolution {46- revision: u64,47- width: u16,48- height: u16,49-}50-51-52-struct EncoderControlDesired {53- revision: u64,54- bitrate: Option<RevisedBitrate>,55- resolution: Option<RevisedResolution>,56- force_keyframe_revision: Option<u64>,57-}58-59-60-pub struct EncoderControlStatus {61- pub latest_revision: u64,62- pub applied_bitrate_revision: Option<u64>,63- pub applied_bitrate_kbps: Option<u32>,64- pub requested_resolution_revision: Option<u64>,65- pub applied_resolution_revision: Option<u64>,66- pub applied_width: Option<u16>,67- pub applied_height: Option<u16>,68- pub requested_force_keyframe_revision: Option<u64>,69- pub acknowledged_force_keyframe_revision: Option<u64>,70-}71-72-/// Bounded latest-state control for an in-process encoder.73-///74-/// Both directions are Tokio watch channels: callers can update control state75-/// arbitrarily often without building a queue, while the encoder applies only76-/// the latest revision. Revisions are process-monotonic and never wrap.77-78-pub struct EncoderControl {79- initial_width: u16,80- initial_height: u16,81- next_revision: Arc<AtomicU64>,82- desired: watch::Sender<EncoderControlDesired>,83- status: watch::Receiver<EncoderControlStatus>,84-}85-86-87-88-89-90-impl EncoderControl {91- fn new(92- initial_width: u16,93- initial_height: u16,94- ) -> (95- Self,96- watch::Receiver<EncoderControlDesired>,97- watch::Sender<EncoderControlStatus>,98- ) {99- let (desired, desired_rx) = watch::channel(EncoderControlDesired::default());100- let (status_tx, status) = watch::channel(EncoderControlStatus::default());101- (102- Self {103- initial_width,104- initial_height,105- next_revision: Arc::new(AtomicU64::new(0)),106- desired,107- status,108- },109- desired_rx,110- status_tx,111- )112- }113-114- pub(crate) fn initial_dimensions(&self) -> (u16, u16) {115- (self.initial_width, self.initial_height)116- }117-118- fn next_revision(&self) -> Result<u64> {119- self.next_revision120- .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |revision| {121- revision.checked_add(1)122- })123- .map(|revision| revision + 1)124- .map_err(|_| anyhow::anyhow!("encoder control revision exhausted"))125- }126-127- pub fn request_bitrate_kbps(&self, kbps: u32) -> Result<u64> {128- ensure!(129- (1..=MAX_ENCODER_BITRATE_KBPS).contains(&kbps),130- "encoder bitrate must be between 1 and {MAX_ENCODER_BITRATE_KBPS} kbps"131- );132- let revision = self.next_revision()?;133- self.desired.send_modify(|desired| {134- desired.revision = revision;135- desired.bitrate = Some(RevisedBitrate { revision, kbps });136- });137- Ok(revision)138- }139-140- pub fn request_force_keyframe(&self) -> Result<u64> {141- let revision = self.next_revision()?;142- self.desired.send_modify(|desired| {143- desired.revision = revision;144- desired.force_keyframe_revision = Some(revision);145- });146- Ok(revision)147- }148-149- pub fn request_resolution(&self, width: u16, height: u16) -> Result<u64> {150- ensure!(151- width >= 64 && height >= 64,152- "encoder dimensions must be at least 64x64"153- );154- ensure!(155- width.is_multiple_of(2) && height.is_multiple_of(2),156- "H.264 encoder dimensions must be even"157- );158- let revision = self.next_revision()?;159- self.desired.send_modify(|desired| {160- desired.revision = revision;161- desired.resolution = Some(RevisedResolution {162- revision,163- width,164- height,165- });166- });167- Ok(revision)168- }169-170- pub fn status(&self) -> EncoderControlStatus {171- *self.status.borrow()172- }173-174- /// Wait until the encoder confirms the exact bitrate revision and exposes175- /// its property readback. A newer applied revision means this request was176- /// coalesced before application and therefore fails rather than pretending177- /// the requested revision committed.178- pub async fn wait_for_bitrate_applied(179- &self,180- revision: u64,181- timeout: Duration,182- ) -> Result<EncoderControlStatus> {183- let mut status = self.status.clone();184- let wait = async {185- loop {186- let snapshot = *status.borrow_and_update();187- match snapshot.applied_bitrate_revision {188- Some(applied) if applied == revision => return Ok(snapshot),189- Some(applied) if applied > revision => bail!(190- "encoder bitrate revision {revision} was superseded by revision {applied}"191- ),192- _ => {}193- }194- status195- .changed()196- .await197- .context("encoder control status closed before bitrate was applied")?;198- }199- };200- tokio::time::timeout(timeout, wait)201- .await202- .with_context(|| format!("timed out waiting for encoder bitrate revision {revision}"))?203- }204-205- /// Wait for a forced-keyframe request to be committed by observing a206- /// keyframe that also carries codec configuration.207- pub async fn wait_for_force_keyframe_acknowledged(208- &self,209- revision: u64,210- timeout: Duration,211- ) -> Result<EncoderControlStatus> {212- let mut status = self.status.clone();213- let wait = async {214- loop {215- let snapshot = *status.borrow_and_update();216- match snapshot.acknowledged_force_keyframe_revision {217- Some(acknowledged) if acknowledged == revision => return Ok(snapshot),218- Some(acknowledged) if acknowledged > revision => bail!(219- "force-keyframe revision {revision} was superseded by revision {acknowledged}"220- ),221- _ => {}222- }223- if snapshot224- .requested_force_keyframe_revision225- .is_some_and(|requested| requested > revision)226- {227- bail!("force-keyframe revision {revision} was coalesced before application");228- }229- status.changed().await.context(230- "encoder control status closed before forced keyframe was acknowledged",231- )?;232- }233- };234- tokio::time::timeout(timeout, wait)235- .await236- .with_context(|| format!("timed out waiting for force-keyframe revision {revision}"))?237- }238-239- /// Wait for recovery to reach any configured IDR at or after `revision`.240- ///241- /// Recovery requests are deliberately coalesced with later encoder242- /// controls. Unlike transactional adaptive updates, a newer configured IDR243- /// still satisfies the decoder barrier established by an older request.244- pub async fn wait_for_recovery_keyframe_acknowledged(245- &self,246- revision: u64,247- timeout: Duration,248- ) -> Result<EncoderControlStatus> {249- let mut status = self.status.clone();250- let wait = async {251- loop {252- let snapshot = *status.borrow_and_update();253- if snapshot254- .acknowledged_force_keyframe_revision255- .is_some_and(|acknowledged| acknowledged >= revision)256- {257- return Ok(snapshot);258- }259- status.changed().await.context(260- "encoder control status closed before recovery keyframe was acknowledged",261- )?;262- }263- };264- tokio::time::timeout(timeout, wait).await.with_context(|| {265- format!("timed out waiting for recovery keyframe revision {revision}")266- })?267- }268-269- /// Wait until the exact target dimensions emerge from the encoder on an270- /// independently decodable access unit. A property write or caps event is271- /// not an application acknowledgement.272- pub async fn wait_for_resolution_applied(273- &self,274- revision: u64,275- width: u16,276- height: u16,277- timeout: Duration,278- ) -> Result<EncoderControlStatus> {279- let mut status = self.status.clone();280- let wait = async {281- loop {282- let snapshot = *status.borrow_and_update();283- match snapshot.applied_resolution_revision {284- Some(applied) if applied == revision => {285- ensure!(286- snapshot.applied_width == Some(width)287- && snapshot.applied_height == Some(height),288- "encoder resolution revision {revision} acknowledged {:?}x{:?}, expected {width}x{height}",289- snapshot.applied_width,290- snapshot.applied_height291- );292- return Ok(snapshot);293- }294- Some(applied) if applied > revision => bail!(295- "encoder resolution revision {revision} was superseded by revision {applied}"296- ),297- _ => {}298- }299- if snapshot300- .requested_resolution_revision301- .is_some_and(|requested| requested > revision)302- {303- bail!(304- "encoder resolution revision {revision} was coalesced before application"305- );306- }307- status308- .changed()309- .await310- .context("encoder control status closed before resolution was applied")?;311- }312- };313- tokio::time::timeout(timeout, wait).await.with_context(|| {314- format!("timed out waiting for encoder resolution revision {revision}")315- })?316- }317-}318-319-320-pub(crate) struct EncoderControlTestHarness {321- pub control: EncoderControl,322- pub status: watch::Sender<EncoderControlStatus>,323- _desired: watch::Receiver<EncoderControlDesired>,324-}325-326-327-impl EncoderControlTestHarness {328- pub(crate) fn new() -> Self {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);334- Self {335- control,336- status,337- _desired: desired,338- }339- }340-341- pub(crate) fn requested_force_keyframe_revision(&self) -> Option<u64> {342- self._desired.borrow().force_keyframe_revision343- }344-}345-346-347-pub struct EncodedFrame {348- pub sequence: u64,349- pub width: u16,350- pub height: u16,351- /// Session-monotonic timestamp when the complete encoded access unit became352- /// observable to the daemon, using the same epoch as audio. Raw PipeWire353- /// capture PTS is not preserved by the current stdout bridge, so this must354- /// not be presented as capture age.355- pub capture_timestamp_micros: u64,356- /// Post-encode observation PTS in the shared session clock. This preserves357- /// real gaps in damage-driven video output.358- pub presentation_timestamp_micros: i64,359- pub observed_at: Instant,360- pub keyframe: bool,361- pub codec_config: bool,362- /// This configured keyframe begins a decoder generation that must not use363- /// reference state from the preceding encoded resolution.364- pub discontinuity: bool,365- pub data: Arc<[u8]>,366-}367-368-369-pub(crate) struct EncodedGop {370- pub frames: Vec<EncodedFrame>,371- pub(crate) payload_bytes: usize,372-}373-374-pub struct EncodedSource {375- pub frames: watch::Receiver<Option<EncodedFrame>>,376- /// A bounded snapshot of every access unit from the latest IDR through the377- /// current frame. A new subscriber can therefore reconstruct the current378- /// static image without receiving an orphan inter-frame.379- pub(crate) current_gop: watch::Receiver<Option<EncodedGop>>,380- pub task: tokio::task::JoinHandle<Result<()>>,381- pub pointer_surface_dimensions: Option<PointerSurfaceDimensions>,382- pub encoder_control: Option<EncoderControl>,383-}384-385-pub fn spawn_test_pattern(config: HostConfig, session_clock: SessionClock) -> EncodedSource {386- let (sender, receiver) = watch::channel(None);387- let (current_gop_sender, current_gop) = watch::channel(None);388- let task = tokio::spawn(async move {389- run_test_pattern(config, session_clock, sender, current_gop_sender).await390- });391- EncodedSource {392- frames: receiver,393- current_gop,394- task,395- pointer_surface_dimensions: None,396- encoder_control: None,397- }398-}399-400-401-pub struct GamescopePreflight {402- /// PipeWire object.serial, resolved from exact configured properties.403- pub target_object: String,404- /// Native Gamescope surface used by the relative-pointer coordinate space.405- pub pointer_surface_dimensions: PointerSurfaceDimensions,406- /// Bounded encoded mode after applying an optional explicit size override.407- pub video_mode: CaptureVideoMode,408-}409-410-411-pub struct CaptureVideoMode {412- pub width: u16,413- pub height: u16,414- pub framerate: u32,415-}416-417-impl CaptureVideoMode {418- fn resolve(config: &HostConfig, native: PointerSurfaceDimensions) -> Result<Self> {419- let configured = config.configured_dimensions();420- let (width, height) =421- configured.unwrap_or((u32::from(native.width), u32::from(native.height)));422- ensure!(423- (64..=7_680).contains(&width) && (64..=4_320).contains(&height),424- "resolved Gamescope encoded dimensions {width}x{height} are outside H.264 bounds"425- );426- ensure!(427- width.is_multiple_of(2) && height.is_multiple_of(2),428- "resolved Gamescope encoded dimensions {width}x{height} must be even"429- );430- if configured.is_some() {431- ensure!(432- u64::from(width) * u64::from(native.height)433- == u64::from(height) * u64::from(native.width),434- "configured Gamescope encoded dimensions {width}x{height} must preserve native {}x{} aspect ratio",435- native.width,436- native.height437- );438- }439- Ok(Self {440- width: u16::try_from(width).context("resolved Gamescope width exceeds metadata")?,441- height: u16::try_from(height).context("resolved Gamescope height exceeds metadata")?,442- framerate: config.framerate,443- })444- }445-}446-447-pub async fn preflight_gamescope_pipewire(config: &HostConfig) -> Result<GamescopePreflight> {448- config.validate()?;449- preflight_gamescope_static(config).await?;450- resolve_gamescope_pipewire_target(config).await451-}452-453-async fn preflight_gamescope_static(config: &HostConfig) -> Result<()> {454- let pipewire = gamescope_config(config)?;455-456- for (name, path) in [457- ("pw_dump_path", &pipewire.pw_dump_path),458- ("gst_inspect_path", &pipewire.gst_inspect_path),459- ] {460- validate_executable(name, path)?;461- }462- if pipewire.encoder_backend == GamescopeEncoderBackend::ExternalGstLaunch {463- validate_executable("gst_launch_path", &pipewire.gst_launch_path)?;464- }465- validate_amd_render_node(&pipewire.vaapi_render_node)?;466-467- let terminal_sink = match pipewire.encoder_backend {468- GamescopeEncoderBackend::ExternalGstLaunch => "fdsink",469- GamescopeEncoderBackend::InProcessGstreamer => "appsink",470- };471- for element in [472- "pipewiresrc",473- "queue",474- "videoconvert",475- "videoscale",476- "videorate",477- "capsfilter",478- pipewire.vaapi_encoder.as_str(),479- "h264parse",480- terminal_sink,481- ] {482- let mut command = Command::new(&pipewire.gst_inspect_path);483- command.arg(element).env("LC_ALL", "C");484- let output = run_bounded_command(command, MAX_INSPECT_OUTPUT).await?;485- ensure!(486- output.status.success(),487- "GStreamer element {element:?} is unavailable: {}",488- diagnostic(&output.stderr)489- );490- if element == pipewire.vaapi_encoder {491- validate_encoder_inspection(&output.stdout, pipewire)?;492- }493- }494-495- Ok(())496-}497-498-async fn resolve_gamescope_pipewire_target(config: &HostConfig) -> Result<GamescopePreflight> {499- config.validate()?;500- let pipewire = gamescope_config(config)?;501- let mut command = Command::new(&pipewire.pw_dump_path);502- command.env("LC_ALL", "C");503- let output = run_bounded_command(command, MAX_PW_DUMP_OUTPUT).await?;504- ensure!(505- output.status.success(),506- "pw-dump failed: {}",507- diagnostic(&output.stderr)508- );509- resolve_pipewire_node(&output.stdout, config)510-}511-512-pub async fn spawn_gamescope_pipewire(513- config: HostConfig,514- session_clock: SessionClock,515-) -> Result<EncodedSource> {516- let preflight = preflight_gamescope_pipewire(&config).await?;517- spawn_gamescope_pipewire_with_preflight(config, session_clock, preflight)518-}519-520-/// Start capture after `serve` has completed the static executable/GstVA521-/// preflight. The PipeWire node is still resolved immediately before each522-/// session because its object serial can change when Gamescope restarts.523-pub async fn spawn_gamescope_pipewire_after_static_preflight(524- config: HostConfig,525- session_clock: SessionClock,526-) -> Result<EncodedSource> {527- let preflight = resolve_gamescope_pipewire_target(&config).await?;528- spawn_gamescope_pipewire_with_preflight(config, session_clock, preflight)529-}530-531-fn spawn_gamescope_pipewire_with_preflight(532- config: HostConfig,533- session_clock: SessionClock,534- preflight: GamescopePreflight,535-) -> Result<EncodedSource> {536- match gamescope_config(&config)?.encoder_backend {537- GamescopeEncoderBackend::ExternalGstLaunch => {538- spawn_gamescope_pipewire_with_target(config, session_clock, preflight)539- }540- GamescopeEncoderBackend::InProcessGstreamer => {541- 542- {543- spawn_gamescope_pipewire_in_process_with_target(config, session_clock, preflight)544- }545- 546- {547- let _ = (session_clock, preflight);548- bail!(549- "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"550- )551- }552- }553- }554-}555-556-fn spawn_gamescope_pipewire_with_target(557- config: HostConfig,558- session_clock: SessionClock,559- preflight: GamescopePreflight,560-) -> Result<EncodedSource> {561- let (sender, receiver) = watch::channel(None);562- let (current_gop_sender, current_gop) = watch::channel(None);563- let target_object = preflight.target_object;564- let video_mode = preflight.video_mode;565- let task = tokio::spawn(async move {566- run_gamescope_pipewire(567- config,568- session_clock,569- target_object,570- video_mode,571- sender,572- current_gop_sender,573- )574- .await575- });576- Ok(EncodedSource {577- frames: receiver,578- current_gop,579- task,580- pointer_surface_dimensions: Some(preflight.pointer_surface_dimensions),581- encoder_control: None,582- })583-}584-585-586-fn spawn_gamescope_pipewire_in_process_with_target(587- config: HostConfig,588- session_clock: SessionClock,589- preflight: GamescopePreflight,590-) -> Result<EncodedSource> {591- let description = gamescope_in_process_pipeline_description(592- &config,593- &preflight.target_object,594- preflight.video_mode,595- )?;596- let max_gop_frames = interactive_gop_frames(preflight.video_mode.framerate) as usize;597- let expected_device_path = Some(598- gamescope_config(&config)?599- .vaapi_render_node600- .to_string_lossy()601- .into_owned(),602- );603- spawn_in_process_pipeline(604- description,605- session_clock,606- max_gop_frames,607- Some(preflight.pointer_surface_dimensions),608- expected_device_path,609- preflight.video_mode.width,610- preflight.video_mode.height,611- )612-}613-614-615-fn spawn_in_process_pipeline(616- description: String,617- session_clock: SessionClock,618- max_gop_frames: usize,619- pointer_surface_dimensions: Option<PointerSurfaceDimensions>,620- expected_device_path: Option<String>,621- initial_width: u16,622- initial_height: u16,623-) -> Result<EncodedSource> {624- let (sender, receiver) = watch::channel(None);625- let (current_gop_sender, current_gop) = watch::channel(None);626- let (control, desired, status) = EncoderControl::new(initial_width, initial_height);627- let task = tokio::spawn(async move {628- tokio::task::spawn_blocking(move || {629- run_in_process_pipeline(630- &description,631- session_clock,632- sender,633- current_gop_sender,634- max_gop_frames,635- desired,636- status,637- expected_device_path.as_deref(),638- initial_width,639- initial_height,640- )641- })642- .await643- .context("in-process GStreamer worker panicked")?644- });645- Ok(EncodedSource {646- frames: receiver,647- current_gop,648- task,649- pointer_surface_dimensions,650- encoder_control: Some(control),651- })652-}653-654-655-fn gamescope_in_process_pipeline_description(656- config: &HostConfig,657- target_object: &str,658- video_mode: CaptureVideoMode,659-) -> Result<String> {660- let pipewire = gamescope_config(config)?;661- validate_pipewire_target_object(target_object)?;662- ensure!(663- pipewire.rate_control == VaapiRateControl::Cbr,664- "in-process GStreamer requires CBR so bitrate can be changed while playing"665- );666- let bitrate = pipewire667- .bitrate_kbps668- .context("CBR bitrate is missing after validation")?;669- let raw_caps = format!(670- "video/x-raw,format=NV12,width={},height={},framerate={}/1",671- video_mode.width, video_mode.height, video_mode.framerate672- );673- Ok(format!(674- "pipewiresrc do-timestamp=true min-buffers=1 max-buffers=4 use-bufferpool=false target-object={target_object} \675- ! queue max-size-buffers=1 max-size-bytes=0 max-size-time=0 leaky=downstream \676- ! videorate drop-only=true max-rate={} \677- ! videoconvert ! videoscale \678- ! capsfilter name=sigil_scale_caps caps={raw_caps} caps-change-mode=delayed \679- ! {} name=sigil_encoder rate-control=cbr bitrate={bitrate} target-usage=7 key-int-max={} b-frames=0 ref-frames=1 aud=true \680- ! h264parse config-interval=-1 \681- ! video/x-h264,stream-format=byte-stream,alignment=au \682- ! appsink name=sigil_sink max-buffers=1 drop=false sync=false",683- video_mode.framerate,684- pipewire.vaapi_encoder,685- interactive_gop_frames(video_mode.framerate),686- ))687-}688-689-690-fn validate_pipewire_target_object(target_object: &str) -> Result<()> {691- ensure!(692- !target_object.is_empty()693- && target_object.len() <= 32694- && target_object.bytes().all(|byte| byte.is_ascii_digit()),695- "resolved PipeWire object.serial is invalid"696- );697- Ok(())698-}699-700-async fn run_gamescope_pipewire(701- config: HostConfig,702- session_clock: SessionClock,703- target_object: String,704- video_mode: CaptureVideoMode,705- sender: watch::Sender<Option<EncodedFrame>>,706- current_gop_sender: watch::Sender<Option<EncodedGop>>,707-) -> Result<()> {708- let pipewire = gamescope_config(&config)?;709- let args = gamescope_pipeline_args(&config, &target_object, video_mode)?;710- let mut command = Command::new(&pipewire.gst_launch_path);711- command712- .args(args)713- .env("LC_ALL", "C")714- // This is honored by the legacy VAAPI plugin and harmless for GstVA.715- // GstVA selection is independently checked against device-path above.716- .env("GST_VAAPI_DRM_DEVICE", &pipewire.vaapi_render_node)717- .stdin(Stdio::null())718- .stdout(Stdio::piped())719- .stderr(Stdio::piped())720- .kill_on_drop(true);721-722- let mut child = command.spawn().with_context(|| {723- format!(724- "starting Gamescope pipeline with {}",725- pipewire.gst_launch_path.display()726- )727- })?;728- let mut stdout = child729- .stdout730- .take()731- .context("GStreamer stdout was not piped")?;732- let stderr = child733- .stderr734- .take()735- .context("GStreamer stderr was not piped")?;736- let stderr_task = tokio::spawn(log_stderr_chunks(stderr, "gstreamer"));737-738- let result = forward_annex_b_stream(739- session_clock,740- &mut stdout,741- &sender,742- ¤t_gop_sender,743- interactive_gop_frames(video_mode.framerate) as usize,744- video_mode.width,745- video_mode.height,746- )747- .await;748- let _ = child.kill().await;749- let exit_status = child.wait().await.ok();750- stderr_task.abort();751-752- match result {753- Ok(()) => Ok(()),754- Err(error) => {755- if let Some(status) = exit_status {756- Err(error).with_context(|| format!("GStreamer pipeline exited with {status}"))757- } else {758- Err(error)759- }760- }761- }762-}763-764-async fn run_test_pattern(765- config: HostConfig,766- session_clock: SessionClock,767- sender: watch::Sender<Option<EncodedFrame>>,768- current_gop_sender: watch::Sender<Option<EncodedGop>>,769-) -> Result<()> {770- let (width, height) = config.test_pattern_dimensions()?;771- let input = format!(772- "testsrc2=size={}x{}:rate={}",773- width, height, config.framerate774- );775- let gop = interactive_gop_frames(config.framerate).to_string();776-777- let mut command = Command::new(&config.ffmpeg_path);778- command779- .arg("-hide_banner")780- .arg("-loglevel")781- .arg("warning")782- .arg("-re")783- .arg("-f")784- .arg("lavfi")785- .arg("-i")786- .arg(input)787- .arg("-an")788- .arg("-c:v")789- .arg("libx264")790- .arg("-preset")791- .arg("ultrafast")792- .arg("-tune")793- .arg("zerolatency")794- .arg("-g")795- .arg(&gop)796- .arg("-keyint_min")797- .arg(&gop)798- .arg("-sc_threshold")799- .arg("0")800- .arg("-bf")801- .arg("0")802- .arg("-pix_fmt")803- .arg("yuv420p")804- .arg("-bsf:v")805- .arg("h264_metadata=aud=insert")806- .arg("-f")807- .arg("h264")808- .arg("pipe:1")809- .stdin(Stdio::null())810- .stdout(Stdio::piped())811- .stderr(Stdio::piped())812- .kill_on_drop(true);813-814- let mut child = command815- .spawn()816- .with_context(|| format!("starting {}", config.ffmpeg_path.display()))?;817- let mut stdout = child.stdout.take().context("ffmpeg stdout was not piped")?;818- let stderr = child.stderr.take().context("ffmpeg stderr was not piped")?;819- let stderr_task = tokio::spawn(log_stderr_chunks(stderr, "ffmpeg"));820-821- let result = forward_annex_b_stream(822- session_clock,823- &mut stdout,824- &sender,825- ¤t_gop_sender,826- interactive_gop_frames(config.framerate) as usize,827- u16::try_from(width).context("configured width exceeds encoder metadata")?,828- u16::try_from(height).context("configured height exceeds encoder metadata")?,829- )830- .await;831- if result.is_ok() {832- let _ = child.kill().await;833- }834- let status = child.wait().await.context("waiting for ffmpeg")?;835- stderr_task.abort();836- match result {837- Ok(()) => Ok(()),838- Err(error) => Err(error).with_context(|| format!("ffmpeg exited with {status}")),839- }840-}841-842-async fn forward_annex_b_stream<R>(843- session_clock: SessionClock,844- stdout: &mut R,845- sender: &watch::Sender<Option<EncodedFrame>>,846- current_gop_sender: &watch::Sender<Option<EncodedGop>>,847- max_gop_frames: usize,848- width: u16,849- height: u16,850-) -> Result<()>851-where852- R: AsyncRead + Unpin,853-{854- let mut publisher = AccessUnitPublisher::new(855- session_clock,856- sender,857- current_gop_sender,858- max_gop_frames,859- width,860- height,861- );862- let mut parser = AnnexBAccessUnitParser::default();863- let mut chunk = [0_u8; 16 * 1024];864-865- loop {866- let count = stdout867- .read(&mut chunk)868- .await869- .context("reading encoded source output")?;870- if count == 0 {871- bail!("encoded source ended unexpectedly");872- }873-874- for access_unit in parser.push(&chunk[..count])? {875- publisher.publish(access_unit)?;876- if publisher.receivers_closed() {877- debug!("encoded source has no receivers");878- return Ok(());879- }880- }881- }882-}883-884-struct AccessUnitPublisher<'a> {885- session_clock: SessionClock,886- sender: &'a watch::Sender<Option<EncodedFrame>>,887- current_gop_sender: &'a watch::Sender<Option<EncodedGop>>,888- max_gop_frames: usize,889- width: u16,890- height: u16,891- sequence: u64,892-}893-894-895-896-struct PendingResolutionTransition {897- target: RevisedResolution,898- deadline: Instant,899-}900-901-902-fn classify_resolution_sample(903- current: (u16, u16),904- pending: Option<PendingResolutionTransition>,905- sample: (u16, u16),906- configured_idr: bool,907-) -> Result<Option<bool>> {908- if let Some(pending) = pending {909- if sample != (pending.target.width, pending.target.height) || !configured_idr {910- return Ok(None);911- }912- return Ok(Some(true));913- }914- ensure!(915- sample == current,916- "encoder changed resolution from {}x{} to {}x{} without a pending control revision",917- current.0,918- current.1,919- sample.0,920- sample.1921- );922- Ok(Some(false))923-}924-925-impl<'a> AccessUnitPublisher<'a> {926- fn new(927- session_clock: SessionClock,928- sender: &'a watch::Sender<Option<EncodedFrame>>,929- current_gop_sender: &'a watch::Sender<Option<EncodedGop>>,930- max_gop_frames: usize,931- width: u16,932- height: u16,933- ) -> Self {934- Self {935- session_clock,936- sender,937- current_gop_sender,938- max_gop_frames,939- width,940- height,941- sequence: 0,942- }943- }944-945- fn publish(&mut self, access_unit: Vec<u8>) -> Result<bool> {946- self.publish_with_metadata(access_unit, self.width, self.height, false)947- }948-949- fn publish_with_metadata(950- &mut self,951- access_unit: Vec<u8>,952- width: u16,953- height: u16,954- discontinuity: bool,955- ) -> Result<bool> {956- ensure!(!access_unit.is_empty(), "encoded access unit is empty");957- ensure!(958- access_unit.len() <= MAX_ENCODE_BUFFER,959- "encoded access unit exceeds {MAX_ENCODE_BUFFER} bytes"960- );961- let observed_at = Instant::now();962- let capture_timestamp_micros = self.session_clock.micros_at(observed_at);963- let presentation_timestamp_micros =964- i64::try_from(capture_timestamp_micros).unwrap_or(i64::MAX);965- let keyframe = is_h264_keyframe(&access_unit);966- let codec_config = has_h264_codec_config(&access_unit);967- let frame = EncodedFrame {968- sequence: self.sequence,969- width,970- height,971- capture_timestamp_micros,972- presentation_timestamp_micros,973- observed_at,974- keyframe,975- codec_config,976- discontinuity,977- data: Arc::from(access_unit),978- };979- self.sequence = self980- .sequence981- .checked_add(1)982- .context("encoded frame sequence exhausted")?;983- publish_encoded_frame(984- self.sender,985- self.current_gop_sender,986- frame,987- self.max_gop_frames,988- MAX_CURRENT_GOP_BYTES,989- );990- self.width = width;991- self.height = height;992- Ok(keyframe && codec_config)993- }994-995- fn receivers_closed(&self) -> bool {996- self.sender.is_closed() && self.current_gop_sender.is_closed()997- }998-}999-1000-1001-fn run_in_process_pipeline(1002- description: &str,1003- session_clock: SessionClock,1004- sender: watch::Sender<Option<EncodedFrame>>,1005- current_gop_sender: watch::Sender<Option<EncodedGop>>,1006- max_gop_frames: usize,1007- mut desired: watch::Receiver<EncoderControlDesired>,1008- status: watch::Sender<EncoderControlStatus>,1009- expected_device_path: Option<&str>,1010- initial_width: u16,1011- initial_height: u16,1012-) -> Result<()> {1013- use gstreamer as gst;1014- use gstreamer::prelude::*;1015- use gstreamer_app as gst_app;1016- gst::init().context("initializing in-process GStreamer")?;1017- let pipeline = gst::parse::launch(description)1018- .context("parsing in-process GStreamer pipeline")?1019- .downcast::<gst::Pipeline>()1020- .map_err(|_| {1021- anyhow::anyhow!("in-process GStreamer description did not create a pipeline")1022- })?;1023- let encoder = pipeline1024- .by_name("sigil_encoder")1025- .context("in-process GStreamer pipeline has no named encoder")?;1026- let scale_caps = pipeline1027- .by_name("sigil_scale_caps")1028- .context("in-process GStreamer pipeline has no named scale capsfilter")?;1029- let sink = pipeline1030- .by_name("sigil_sink")1031- .context("in-process GStreamer pipeline has no named appsink")?1032- .downcast::<gst_app::AppSink>()1033- .map_err(|_| anyhow::anyhow!("named in-process sink is not an appsink"))?;1034- sink.set_max_buffers(1);1035- // Encoded dependent AUs must not be dropped here: the raw queue before the1036- // encoder is the only latest-frame boundary. This one-buffer sink applies1037- // bounded backpressure until the publishing loop consumes the AU.1038- sink.set_drop(false);1039- sink.set_sync(false);1040- validate_mutable_playing_bitrate(&encoder)?;1041- validate_resolution_capsfilter(&scale_caps)?;1042-1043- pipeline1044- .set_state(gst::State::Playing)1045- .context("starting in-process GStreamer pipeline")?;1046- let result = (|| {1047- let (state_change, state, pending) = pipeline.state(gst::ClockTime::from_seconds(5));1048- state_change.context("in-process GStreamer pipeline failed while waiting for PLAYING")?;1049- ensure!(1050- state == gst::State::Playing,1051- "in-process GStreamer pipeline did not reach PLAYING within 5 seconds (state {state:?}, pending {pending:?})"1052- );1053- if let Some(expected_device_path) = expected_device_path {1054- validate_active_encoder_device_path(&encoder, expected_device_path)?;1055- }1056- let bus = pipeline1057- .bus()1058- .context("in-process GStreamer pipeline has no bus")?;1059- let mut publisher = AccessUnitPublisher::new(1060- session_clock,1061- &sender,1062- ¤t_gop_sender,1063- max_gop_frames,1064- initial_width,1065- initial_height,1066- );1067- let mut applied_desired_revision = 0_u64;1068- let mut pending_force_keyframe_revision = None;1069- let mut pending_resolution = None;1070- status.send_modify(|status| {1071- status.applied_width = Some(initial_width);1072- status.applied_height = Some(initial_height);1073- });1074-1075- loop {1076- if let Some(message) = bus.timed_pop(gst::ClockTime::ZERO) {1077- match message.view() {1078- gst::MessageView::Error(error) => {1079- bail!(1080- "in-process GStreamer pipeline error from {:?}: {} ({:?})",1081- error.src().map(|source| source.path_string()),1082- error.error(),1083- error.debug()1084- );1085- }1086- gst::MessageView::Eos(_) => {1087- bail!("in-process GStreamer pipeline ended unexpectedly");1088- }1089- _ => {}1090- }1091- }1092-1093- let desired_state = *desired.borrow_and_update();1094- if desired_state.revision > applied_desired_revision {1095- apply_encoder_control(1096- &encoder,1097- &scale_caps,1098- &sink,1099- desired_state,1100- &status,1101- ¤t_gop_sender,1102- &mut pending_force_keyframe_revision,1103- &mut pending_resolution,1104- )?;1105- applied_desired_revision = desired_state.revision;1106- }1107-1108- if pending_resolution.is_some_and(|pending: PendingResolutionTransition| {1109- Instant::now() >= pending.deadline1110- }) {1111- let pending = pending_resolution.expect("checked as present");1112- bail!(1113- "encoder resolution revision {} did not produce a configured {}x{} IDR within {:?}",1114- pending.target.revision,1115- pending.target.width,1116- pending.target.height,1117- RESOLUTION_APPLY_TIMEOUT1118- );1119- }1120-1121- if let Some(sample) = sink.try_pull_sample(gst::ClockTime::from_mseconds(50)) {1122- let (width, height) = encoded_sample_dimensions(&sample)?;1123- let buffer = sample1124- .buffer()1125- .context("in-process appsink sample has no buffer")?;1126- let map = buffer1127- .map_readable()1128- .context("mapping in-process encoded access unit")?;1129- ensure!(1130- map.as_slice().len() <= MAX_ENCODE_BUFFER,1131- "in-process encoded access unit exceeds {MAX_ENCODE_BUFFER} bytes"1132- );1133- let access_unit = map.as_slice();1134- let configured_idr =1135- is_h264_keyframe(access_unit) && has_h264_codec_config(access_unit);1136- let Some(discontinuity) = classify_resolution_sample(1137- (publisher.width, publisher.height),1138- pending_resolution,1139- (width, height),1140- configured_idr,1141- )?1142- else {1143- continue;1144- };1145- let configured_idr = publisher.publish_with_metadata(1146- access_unit.to_vec(),1147- width,1148- height,1149- discontinuity,1150- )?;1151- if discontinuity {1152- let applied = pending_resolution1153- .take()1154- .expect("discontinuity requires a pending resolution");1155- status.send_modify(|status| {1156- status.applied_resolution_revision = Some(applied.target.revision);1157- status.applied_width = Some(width);1158- status.applied_height = Some(height);1159- });1160- }1161- acknowledge_force_keyframe_if_configured_idr(1162- configured_idr,1163- &mut pending_force_keyframe_revision,1164- &status,1165- );1166- } else if sink.is_eos() {1167- bail!("in-process GStreamer appsink reached EOS unexpectedly");1168- }1169-1170- if publisher.receivers_closed() {1171- debug!("in-process encoded source has no receivers");1172- return Ok(());1173- }1174- }1175- })();1176- if let Err(error) = pipeline.set_state(gst::State::Null) {1177- warn!(%error, "failed to stop in-process GStreamer pipeline cleanly");1178- }1179- result1180-}1181-1182-1183-fn validate_active_encoder_device_path(1184- encoder: &gstreamer::Element,1185- expected_device_path: &str,1186-) -> Result<()> {1187- use gstreamer as gst;1188- use gstreamer::prelude::*;1189-1190- let property = encoder1191- .find_property("device-path")1192- .context("configured in-process VA encoder has no device-path property")?;1193- ensure!(1194- property.flags().contains(gst::glib::ParamFlags::READABLE),1195- "configured in-process VA encoder device-path property is not readable"1196- );1197- let observed_device_path = encoder.property::<Option<String>>("device-path");1198- ensure!(1199- observed_device_path.as_deref() == Some(expected_device_path),1200- "configured in-process VA encoder uses device-path {observed_device_path:?}, expected {expected_device_path:?}"1201- );1202- Ok(())1203-}1204-1205-1206-fn validate_mutable_playing_bitrate(encoder: &gstreamer::Element) -> Result<()> {1207- use gstreamer as gst;1208- use gstreamer::prelude::*;1209-1210- let property = encoder1211- .find_property("bitrate")1212- .context("configured in-process encoder has no bitrate property")?;1213- ensure!(1214- property.flags().contains(gst::glib::ParamFlags::WRITABLE),1215- "configured in-process encoder bitrate property is not writable"1216- );1217- ensure!(1218- property.flags().contains(gst::PARAM_FLAG_MUTABLE_PLAYING),1219- "configured in-process encoder bitrate property is not mutable while playing"1220- );1221- Ok(())1222-}1223-1224-1225-fn validate_resolution_capsfilter(capsfilter: &gstreamer::Element) -> Result<()> {1226- use gstreamer::prelude::*;1227-1228- let property = capsfilter1229- .find_property("caps")1230- .context("configured scale capsfilter has no caps property")?;1231- ensure!(1232- property1233- .flags()1234- .contains(gstreamer::glib::ParamFlags::WRITABLE),1235- "configured scale capsfilter caps property is not writable"1236- );1237- let caps = capsfilter.property::<gstreamer::Caps>("caps");1238- let _ = dimensions_from_caps(&caps)?;1239- Ok(())1240-}1241-1242-1243-fn dimensions_from_caps(caps: &gstreamer::CapsRef) -> Result<(u16, u16)> {1244- let structure = caps1245- .structure(0)1246- .context("configured video caps have no structure")?;1247- let width = structure1248- .get::<i32>("width")1249- .context("configured video caps have no integer width")?;1250- let height = structure1251- .get::<i32>("height")1252- .context("configured video caps have no integer height")?;1253- Ok((1254- u16::try_from(width).context("configured video caps width is outside u16")?,1255- u16::try_from(height).context("configured video caps height is outside u16")?,1256- ))1257-}1258-1259-1260-fn encoded_sample_dimensions(sample: &gstreamer::Sample) -> Result<(u16, u16)> {1261- let caps = sample1262- .caps()1263- .context("in-process encoded sample has no negotiated caps")?;1264- dimensions_from_caps(caps)1265-}1266-1267-1268-fn apply_resolution_caps(1269- capsfilter: &gstreamer::Element,1270- resolution: RevisedResolution,1271-) -> Result<()> {1272- use gstreamer::prelude::*;1273-1274- let mut caps = capsfilter.property::<gstreamer::Caps>("caps");1275- let caps_mut = caps.make_mut();1276- let structure = caps_mut1277- .structure_mut(0)1278- .context("configured scale capsfilter has no caps structure")?;1279- structure.set("width", i32::from(resolution.width));1280- structure.set("height", i32::from(resolution.height));1281- capsfilter.set_property("caps", caps);1282- let readback = capsfilter.property::<gstreamer::Caps>("caps");1283- ensure!(1284- dimensions_from_caps(&readback)? == (resolution.width, resolution.height),1285- "scale capsfilter resolution readback does not match requested {}x{}",1286- resolution.width,1287- resolution.height1288- );1289- Ok(())1290-}1291-1292-1293-fn push_configured_keyframe_request(sink: &gstreamer_app::AppSink) -> Result<()> {1294- use gstreamer::prelude::*;1295-1296- let event = gstreamer_video::UpstreamForceKeyUnitEvent::builder()1297- .all_headers(true)1298- .build();1299- let sink_pad = sink1300- .static_pad("sink")1301- .context("configured in-process appsink has no sink pad")?;1302- ensure!(1303- sink_pad.push_event(event),1304- "in-process encoder rejected upstream ForceKeyUnit"1305- );1306- Ok(())1307-}1308-1309-1310-fn apply_encoder_control(1311- encoder: &gstreamer::Element,1312- scale_caps: &gstreamer::Element,1313- sink: &gstreamer_app::AppSink,1314- desired: EncoderControlDesired,1315- status: &watch::Sender<EncoderControlStatus>,1316- current_gop_sender: &watch::Sender<Option<EncodedGop>>,1317- pending_force_keyframe_revision: &mut Option<u64>,1318- pending_resolution: &mut Option<PendingResolutionTransition>,1319-) -> Result<()> {1320- let current = *status.borrow();1321- if let Some(bitrate) = desired.bitrate1322- && current1323- .applied_bitrate_revision1324- .is_none_or(|revision| bitrate.revision > revision)1325- {1326- encoder.set_property("bitrate", bitrate.kbps);1327- let readback = encoder.property::<u32>("bitrate");1328- ensure!(1329- readback == bitrate.kbps,1330- "encoder bitrate readback mismatch: requested {} kbps, observed {readback} kbps",1331- bitrate.kbps1332- );1333- status.send_modify(|status| {1334- status.applied_bitrate_revision = Some(bitrate.revision);1335- status.applied_bitrate_kbps = Some(readback);1336- });1337- }1338-1339- let mut keyframe_requested = false;1340- if let Some(resolution) = desired.resolution1341- && current1342- .requested_resolution_revision1343- .is_none_or(|requested| resolution.revision > requested)1344- {1345- apply_resolution_caps(scale_caps, resolution)?;1346- current_gop_sender.send_replace(None);1347- push_configured_keyframe_request(sink)?;1348- keyframe_requested = true;1349- *pending_resolution = Some(PendingResolutionTransition {1350- target: resolution,1351- deadline: Instant::now() + RESOLUTION_APPLY_TIMEOUT,1352- });1353- status.send_modify(|status| {1354- status.requested_resolution_revision = Some(resolution.revision);1355- });1356- }1357-1358- if let Some(revision) = desired.force_keyframe_revision1359- && current1360- .requested_force_keyframe_revision1361- .is_none_or(|requested| revision > requested)1362- {1363- if !keyframe_requested {1364- push_configured_keyframe_request(sink)?;1365- }1366- *pending_force_keyframe_revision = Some(revision);1367- status.send_modify(|status| {1368- status.requested_force_keyframe_revision = Some(revision);1369- });1370- }1371-1372- status.send_modify(|status| status.latest_revision = desired.revision);1373- Ok(())1374-}1375-1376-1377-fn acknowledge_force_keyframe_if_configured_idr(1378- configured_idr: bool,1379- pending_revision: &mut Option<u64>,1380- status: &watch::Sender<EncoderControlStatus>,1381-) {1382- if configured_idr && let Some(revision) = pending_revision.take() {1383- status.send_modify(|status| {1384- status.acknowledged_force_keyframe_revision = Some(revision);1385- });1386- }1387-}1388-1389-fn publish_encoded_frame(1390- sender: &watch::Sender<Option<EncodedFrame>>,1391- current_gop_sender: &watch::Sender<Option<EncodedGop>>,1392- frame: EncodedFrame,1393- max_gop_frames: usize,1394- max_gop_bytes: usize,1395-) {1396- if !current_gop_sender.is_closed() {1397- current_gop_sender.send_if_modified(|current_gop| {1398- update_current_gop(current_gop, frame.clone(), max_gop_frames, max_gop_bytes)1399- });1400- }1401- sender.send_replace(Some(frame.clone()));1402-}1403-1404-fn update_current_gop(1405- current_gop: &mut Option<EncodedGop>,1406- frame: EncodedFrame,1407- max_gop_frames: usize,1408- max_gop_bytes: usize,1409-) -> bool {1410- let independently_decodable = frame.keyframe && frame.codec_config;1411- if independently_decodable {1412- let payload_bytes = frame.data.len();1413- if max_gop_frames == 0 || payload_bytes > max_gop_bytes {1414- return current_gop.take().is_some();1415- }1416- *current_gop = Some(EncodedGop {1417- frames: vec![frame],1418- payload_bytes,1419- });1420- return true;1421- }1422-1423- let Some(gop) = current_gop.as_mut() else {1424- return false;1425- };1426- let contiguous = gop1427- .frames1428- .last()1429- .and_then(|previous| previous.sequence.checked_add(1))1430- == Some(frame.sequence);1431- let payload_bytes = gop.payload_bytes.saturating_add(frame.data.len());1432- if !contiguous || gop.frames.len() >= max_gop_frames || payload_bytes > max_gop_bytes {1433- *current_gop = None;1434- return true;1435- }1436-1437- gop.frames.push(frame);1438- gop.payload_bytes = payload_bytes;1439- true1440-}1441-1442-pub(crate) async fn log_stderr_chunks<R>(mut stderr: R, target: &'static str)1443-where1444- R: AsyncRead + Unpin,1445-{1446- const LOG_INTERVAL: Duration = Duration::from_secs(1);1447- const MAX_LOG_BYTES_PER_INTERVAL: usize = 16 * 1024;1448-1449- let mut chunk = [0_u8; 4096];1450- let mut interval_started = tokio::time::Instant::now();1451- let mut logged_bytes = 0_usize;1452- let mut suppressed_bytes = 0_u64;1453- loop {1454- match stderr.read(&mut chunk).await {1455- Ok(0) => return,1456- Ok(count) => {1457- if interval_started.elapsed() >= LOG_INTERVAL {1458- if suppressed_bytes > 0 {1459- warn!(1460- target: "capture-process",1461- process = target,1462- suppressed_bytes,1463- "capture diagnostics rate limited"1464- );1465- }1466- interval_started = tokio::time::Instant::now();1467- logged_bytes = 0;1468- suppressed_bytes = 0;1469- }1470- if logged_bytes.saturating_add(count) <= MAX_LOG_BYTES_PER_INTERVAL {1471- warn!(target: "capture-process", process = target, "{}", String::from_utf8_lossy(&chunk[..count]).trim_end());1472- logged_bytes = logged_bytes.saturating_add(count);1473- } else {1474- suppressed_bytes = suppressed_bytes.saturating_add(count as u64);1475- }1476- }1477- Err(error) => {1478- warn!(target: "capture-process", process = target, %error, "failed reading stderr");1479- return;1480- }1481- }1482- }1483-}1484-1485-fn gamescope_config(config: &HostConfig) -> Result<&GamescopePipewireConfig> {1486- config1487- .gamescope_pipewire1488- .as_ref()1489- .context("gamescope_pipewire configuration is missing")1490-}1491-1492-fn gamescope_pipeline_args(1493- config: &HostConfig,1494- target_object: &str,1495- video_mode: CaptureVideoMode,1496-) -> Result<Vec<OsString>> {1497- let pipewire = gamescope_config(config)?;1498- ensure!(1499- !target_object.is_empty()1500- && target_object.len() <= 321501- && target_object.bytes().all(|byte| byte.is_ascii_digit()),1502- "resolved PipeWire object.serial is invalid"1503- );1504- let caps = format!(1505- "video/x-raw,format=NV12,width={},height={},framerate={}/1",1506- video_mode.width, video_mode.height, video_mode.framerate1507- );1508- let encoded_caps = "video/x-h264,stream-format=byte-stream,alignment=au";1509- let mut args = vec![1510- "--quiet".to_owned(),1511- "pipewiresrc".to_owned(),1512- "do-timestamp=true".to_owned(),1513- // Keep Gamescope's negotiated producer pool bounded at its preferred1514- // four buffers, but copy each delivered frame into GStreamer's own1515- // buffer before downstream conversion/encoding can retain it. This1516- // returns producer buffers promptly instead of starving Gamescope at1517- // vblank. `use-bufferpool=false` is the supported replacement for the1518- // deprecated `always-copy=true` spelling.1519- "min-buffers=1".to_owned(),1520- "max-buffers=4".to_owned(),1521- "use-bufferpool=false".to_owned(),1522- format!("target-object={target_object}"),1523- "!".to_owned(),1524- "queue".to_owned(),1525- "max-size-buffers=1".to_owned(),1526- "max-size-bytes=0".to_owned(),1527- "max-size-time=0".to_owned(),1528- "leaky=downstream".to_owned(),1529- "!".to_owned(),1530- "videorate".to_owned(),1531- // Gamescope is damage-driven. Never manufacture replacement frames:1532- // doing so can turn an irregular live source into stale catch-up work.1533- "drop-only=true".to_owned(),1534- format!("max-rate={}", video_mode.framerate),1535- "!".to_owned(),1536- "videoconvert".to_owned(),1537- "!".to_owned(),1538- "videoscale".to_owned(),1539- "!".to_owned(),1540- caps,1541- "!".to_owned(),1542- pipewire.vaapi_encoder.clone(),1543- ];1544- args.push(format!(1545- "rate-control={}",1546- match pipewire.rate_control {1547- VaapiRateControl::Cbr => "cbr",1548- VaapiRateControl::Cqp => "cqp",1549- }1550- ));1551- match pipewire.rate_control {1552- VaapiRateControl::Cbr => args.push(format!(1553- "bitrate={}",1554- pipewire1555- .bitrate_kbps1556- .context("CBR bitrate is missing after validation")?1557- )),1558- VaapiRateControl::Cqp => {1559- let quantizer = pipewire1560- .quantizer1561- .context("CQP quantizer is missing after validation")?;1562- args.push(format!("qpi={quantizer}"));1563- args.push(format!("qpp={quantizer}"));1564- }1565- }1566- args.extend([1567- "target-usage=7".to_owned(),1568- format!(1569- "key-int-max={}",1570- interactive_gop_frames(video_mode.framerate)1571- ),1572- "b-frames=0".to_owned(),1573- "ref-frames=1".to_owned(),1574- "aud=true".to_owned(),1575- "!".to_owned(),1576- "h264parse".to_owned(),1577- "config-interval=-1".to_owned(),1578- "!".to_owned(),1579- encoded_caps.to_owned(),1580- "!".to_owned(),1581- "fdsink".to_owned(),1582- "fd=1".to_owned(),1583- // Gamescope's PipeWire timestamps can advance in larger jumps than1584- // the negotiated frame duration. Clocking this terminal sink makes it1585- // wait on those producer timestamps while the one-frame leaky queue1586- // drops fresh motion. Videorate already caps the live stream at the1587- // configured maximum, so drain encoded access units immediately.1588- "sync=false".to_owned(),1589- ]);1590- Ok(args.into_iter().map(OsString::from).collect())1591-}1592-1593-fn interactive_gop_frames(framerate: u32) -> u32 {1594- // A dropped encoded reference requires the client to wait for the next1595- // independently decodable IDR. Bound that recovery to roughly 500 ms until1596- // the in-process encoder can service explicit keyframe requests.1597- framerate.div_ceil(2).max(1)1598-}1599-1600-pub(crate) async fn resolve_pipewire_node_by_properties(1601- pw_dump_path: &std::path::Path,1602- node_name: &str,1603- media_class: &str,1604- match_properties: &std::collections::BTreeMap<String, String>,1605-) -> Result<String> {1606- let mut command = Command::new(pw_dump_path);1607- command.env("LC_ALL", "C");1608- let output = run_bounded_command(command, MAX_PW_DUMP_OUTPUT).await?;1609- ensure!(1610- output.status.success(),1611- "pw-dump failed: {}",1612- diagnostic(&output.stderr)1613- );1614- resolve_pipewire_node_exact(&output.stdout, node_name, media_class, match_properties)1615-}1616-1617-fn resolve_pipewire_node(output: &[u8], config: &HostConfig) -> Result<GamescopePreflight> {1618- let pipewire = gamescope_config(config)?;1619- let target_object = resolve_pipewire_node_exact(1620- output,1621- &pipewire.node_name,1622- &pipewire.media_class,1623- &pipewire.match_properties,1624- )?;1625- let pointer_surface_dimensions = resolve_pointer_surface_dimensions(1626- output,1627- &target_object,1628- &pipewire.node_name,1629- &pipewire.media_class,1630- &pipewire.match_properties,1631- )?;1632- let video_mode = CaptureVideoMode::resolve(config, pointer_surface_dimensions)?;1633- Ok(GamescopePreflight {1634- target_object,1635- pointer_surface_dimensions,1636- video_mode,1637- })1638-}1639-1640-pub(crate) fn resolve_pipewire_node_exact(1641- output: &[u8],1642- node_name: &str,1643- media_class: &str,1644- match_properties: &std::collections::BTreeMap<String, String>,1645-) -> Result<String> {1646- let objects: Value = serde_json::from_slice(output).context("parsing bounded pw-dump JSON")?;1647- let objects = objects1648- .as_array()1649- .context("pw-dump output is not an array")?;1650- let mut matches = Vec::new();1651-1652- for object in objects {1653- if object.get("type").and_then(Value::as_str) != Some("PipeWire:Interface:Node") {1654- continue;1655- }1656- let Some(properties) = object1657- .get("info")1658- .and_then(|info| info.get("props"))1659- .and_then(Value::as_object)1660- else {1661- continue;1662- };1663- if !pipewire_node_matches(properties, node_name, media_class, match_properties) {1664- continue;1665- }1666- let serial = property_string(properties.get("object.serial"))1667- .context("matching PipeWire node has no object.serial")?;1668- ensure!(1669- !serial.is_empty() && serial.bytes().all(|byte| byte.is_ascii_digit()),1670- "matching PipeWire node has invalid object.serial"1671- );1672- matches.push(serial.into_owned());1673- }1674-1675- ensure!(1676- matches.len() == 1,1677- "expected exactly one PipeWire node matching node.name={:?}, media.class={:?}, and configured properties; found {}",1678- node_name,1679- media_class,1680- matches.len()1681- );1682- Ok(matches.remove(0))1683-}1684-1685-fn resolve_pointer_surface_dimensions(1686- output: &[u8],1687- target_object: &str,1688- node_name: &str,1689- media_class: &str,1690- match_properties: &std::collections::BTreeMap<String, String>,1691-) -> Result<PointerSurfaceDimensions> {1692- let objects: Value = serde_json::from_slice(output).context("parsing bounded pw-dump JSON")?;1693- let objects = objects1694- .as_array()1695- .context("pw-dump output is not an array")?;1696- let mut matched_node_count = 0_usize;1697- let mut native_size = None;1698-1699- for object in objects {1700- if object.get("type").and_then(Value::as_str) != Some("PipeWire:Interface:Node") {1701- continue;1702- }1703- let Some(info) = object.get("info").and_then(Value::as_object) else {1704- continue;1705- };1706- let Some(properties) = info.get("props").and_then(Value::as_object) else {1707- continue;1708- };1709- if !pipewire_node_matches(properties, node_name, media_class, match_properties)1710- || property_string(properties.get("object.serial")).as_deref() != Some(target_object)1711- {1712- continue;1713- }1714- matched_node_count += 1;1715- let formats = info1716- .get("params")1717- .and_then(|params| params.get("EnumFormat"))1718- .and_then(Value::as_array)1719- .context("matching Gamescope PipeWire node has no EnumFormat array")?;1720- for format in formats {1721- if format.get("mediaType").and_then(Value::as_str) != Some("video")1722- || format.get("mediaSubtype").and_then(Value::as_str) != Some("raw")1723- {1724- continue;1725- }1726- let size = format1727- .get("size")1728- .context("Gamescope raw EnumFormat has no native size")?;1729- let size = size.get("default").unwrap_or(size);1730- let width = size1731- .get("width")1732- .and_then(Value::as_u64)1733- .and_then(|value| u16::try_from(value).ok())1734- .context("Gamescope raw EnumFormat native width is not a u16")?;1735- let height = size1736- .get("height")1737- .and_then(Value::as_u64)1738- .and_then(|value| u16::try_from(value).ok())1739- .context("Gamescope raw EnumFormat native height is not a u16")?;1740- let dimensions = PointerSurfaceDimensions::new(width, height)1741- .context("Gamescope raw EnumFormat native size is outside pointer bounds")?;1742- if native_size.is_some_and(|existing| existing != dimensions) {1743- bail!("matching Gamescope PipeWire node advertises ambiguous native sizes");1744- }1745- native_size = Some(dimensions);1746- }1747- }1748-1749- ensure!(1750- matched_node_count == 1,1751- "expected exactly one matched Gamescope PipeWire node while resolving its native size; found {matched_node_count}"1752- );1753- native_size.context("matching Gamescope PipeWire node has no raw native EnumFormat size")1754-}1755-1756-fn pipewire_node_matches(1757- properties: &serde_json::Map<String, Value>,1758- node_name: &str,1759- media_class: &str,1760- match_properties: &std::collections::BTreeMap<String, String>,1761-) -> bool {1762- property_string(properties.get("node.name")).as_deref() == Some(node_name)1763- && property_string(properties.get("media.class")).as_deref() == Some(media_class)1764- && match_properties.iter().all(|(key, expected)| {1765- property_string(properties.get(key)).is_some_and(|actual| actual == *expected)1766- })1767-}1768-1769-fn property_string(value: Option<&Value>) -> Option<Cow<'_, str>> {1770- match value? {1771- Value::String(value) => Some(Cow::Borrowed(value)),1772- Value::Number(value) => Some(Cow::Owned(value.to_string())),1773- _ => None,1774- }1775-}1776-1777-fn validate_encoder_inspection(output: &[u8], config: &GamescopePipewireConfig) -> Result<()> {1778- let output = std::str::from_utf8(output).context("gst-inspect output is not UTF-8")?;1779- let lines: Vec<&str> = output.lines().collect();1780- let device_block = gst_property_block(&lines, "device-path")1781- .context("configured VA encoder does not report a device-path property")?;1782- let expected = config.vaapi_render_node.to_string_lossy();1783- let expected_default = format!("Default: \"{expected}\"");1784- ensure!(1785- device_block.contains(&expected_default),1786- "configured VA encoder {:?} does not report expected device-path {}",1787- config.vaapi_encoder,1788- config.vaapi_render_node.display()1789- );1790- for property in [1791- "aud",1792- "b-frames",1793- "key-int-max",1794- "rate-control",1795- "ref-frames",1796- "target-usage",1797- ] {1798- let block = gst_property_block(&lines, property).with_context(|| {1799- format!(1800- "configured VA encoder {:?} has no {property} property",1801- config.vaapi_encoder1802- )1803- })?;1804- ensure!(1805- block.contains("writable") || block.contains("Writable"),1806- "configured VA encoder {:?} property {property} is not writable",1807- config.vaapi_encoder1808- );1809- }1810- let rate_control =1811- gst_property_block(&lines, "rate-control").expect("rate-control block was checked above");1812- let expected_rate_control = match config.rate_control {1813- VaapiRateControl::Cbr => "cbr",1814- VaapiRateControl::Cqp => "cqp",1815- };1816- ensure!(1817- rate_control.contains(expected_rate_control),1818- "configured VA encoder {:?} does not advertise {expected_rate_control} rate control",1819- config.vaapi_encoder1820- );1821- let mode_properties: &[&str] = match config.rate_control {1822- VaapiRateControl::Cbr => &["bitrate"],1823- VaapiRateControl::Cqp => &["qpi", "qpp"],1824- };1825- for property in mode_properties {1826- let block = gst_property_block(&lines, property).with_context(|| {1827- format!(1828- "configured VA encoder {:?} has no {property} property",1829- config.vaapi_encoder1830- )1831- })?;1832- ensure!(1833- block.contains("writable") || block.contains("Writable"),1834- "configured VA encoder {:?} property {property} is not writable",1835- config.vaapi_encoder1836- );1837- }1838- Ok(())1839-}1840-1841-fn gst_property_block(lines: &[&str], property: &str) -> Option<String> {1842- let start = lines.iter().position(|line| {1843- line.strip_prefix(" ").is_some_and(|line| {1844- line.starts_with(property) && line[property.len()..].starts_with(' ')1845- })1846- })?;1847- let end = lines[start + 1..]1848- .iter()1849- .position(|line| {1850- line.strip_prefix(" ")1851- .is_some_and(|line| !line.starts_with(' ') && line.split_once(" : ").is_some())1852- })1853- .map_or(lines.len(), |offset| start + 1 + offset);1854- Some(lines[start..end].join("\n"))1855-}1856-1857-pub(crate) fn validate_executable(name: &str, path: &std::path::Path) -> Result<()> {1858- let metadata = fs::symlink_metadata(path)1859- .with_context(|| format!("inspecting gamescope_pipewire.{name} at {}", path.display()))?;1860- ensure!(1861- !metadata.file_type().is_symlink(),1862- "gamescope_pipewire.{name} must not be a symlink"1863- );1864- ensure!(1865- metadata.is_file(),1866- "gamescope_pipewire.{name} is not a file"1867- );1868- 1869- {1870- use std::os::unix::fs::MetadataExt;1871-1872- ensure!(1873- metadata.permissions().mode() & 0o111 != 0,1874- "gamescope_pipewire.{name} is not executable"1875- );1876- ensure!(1877- metadata.permissions().mode() & 0o022 == 0,1878- "gamescope_pipewire.{name} must not be writable by group or other users"1879- );1880- let owner = metadata.uid();1881- let effective_user = unsafe { libc::geteuid() };1882- ensure!(1883- owner == 0 || owner == effective_user,1884- "gamescope_pipewire.{name} must be owned by root or the current user"1885- );1886- }1887- Ok(())1888-}1889-1890-pub(crate) async fn preflight_gstreamer_element(1891- gst_inspect_path: &std::path::Path,1892- element: &str,1893-) -> Result<()> {1894- let mut command = Command::new(gst_inspect_path);1895- command.arg(element).env("LC_ALL", "C");1896- let output = run_bounded_command(command, MAX_INSPECT_OUTPUT).await?;1897- ensure!(1898- output.status.success(),1899- "GStreamer element {element:?} is unavailable: {}",1900- diagnostic(&output.stderr)1901- );1902- Ok(())1903-}1904-1905-fn validate_amd_render_node(path: &std::path::Path) -> Result<()> {1906- let metadata = fs::symlink_metadata(path)1907- .with_context(|| format!("inspecting VAAPI render node {}", path.display()))?;1908- 1909- ensure!(1910- metadata.file_type().is_char_device(),1911- "VAAPI render node {} is not a character device",1912- path.display()1913- );1914- fs::OpenOptions::new()1915- .read(true)1916- .write(true)1917- .open(path)1918- .with_context(|| {1919- format!(1920- "opening VAAPI render node {} read/write as the current user",1921- path.display()1922- )1923- })?;1924- 1925- {1926- let name = path1927- .file_name()1928- .context("VAAPI render node has no file name")?;1929- let driver = fs::canonicalize(1930- std::path::Path::new("/sys/class/drm")1931- .join(name)1932- .join("device/driver"),1933- )1934- .with_context(|| format!("resolving kernel driver for {}", path.display()))?;1935- ensure!(1936- driver.file_name().and_then(|name| name.to_str()) == Some("amdgpu"),1937- "VAAPI render node {} is not backed by amdgpu",1938- path.display()1939- );1940- }1941- Ok(())1942-}1943-1944-struct BoundedOutput {1945- status: ExitStatus,1946- stdout: Vec<u8>,1947- stderr: Vec<u8>,1948-}1949-1950-async fn run_bounded_command(mut command: Command, stdout_limit: usize) -> Result<BoundedOutput> {1951- command1952- .stdin(Stdio::null())1953- .stdout(Stdio::piped())1954- .stderr(Stdio::piped())1955- .kill_on_drop(true);1956- let program = command.as_std().get_program().to_owned();1957- let mut child = command1958- .spawn()1959- .with_context(|| format!("starting {}", program.to_string_lossy()))?;1960- let stdout = child.stdout.take().context("child stdout was not piped")?;1961- let stderr = child.stderr.take().context("child stderr was not piped")?;1962-1963- let collect = async {1964- let (stdout, stderr) = tokio::join!(1965- read_bounded(stdout, stdout_limit),1966- read_bounded(stderr, MAX_DIAGNOSTIC_OUTPUT)1967- );1968- let status = child1969- .wait()1970- .await1971- .context("waiting for preflight command")?;1972- Ok::<_, anyhow::Error>(BoundedOutput {1973- status,1974- stdout: stdout?,1975- stderr: stderr?,1976- })1977- };1978- match tokio::time::timeout(PREFLIGHT_TIMEOUT, collect).await {1979- Ok(result) => result,1980- Err(_) => {1981- let _ = child.kill().await;1982- let _ = child.wait().await;1983- bail!(1984- "preflight command {} exceeded {} seconds",1985- program.to_string_lossy(),1986- PREFLIGHT_TIMEOUT.as_secs()1987- )1988- }1989- }1990-}1991-1992-async fn read_bounded<R>(reader: R, limit: usize) -> Result<Vec<u8>>1993-where1994- R: AsyncRead + Unpin,1995-{1996- let mut bytes = Vec::new();1997- reader1998- .take(limit.saturating_add(1) as u64)1999- .read_to_end(&mut bytes)2000- .await2001- .context("reading bounded command output")?;2002- ensure!(2003- bytes.len() <= limit,2004- "command output exceeded {limit} bytes"2005- );2006- Ok(bytes)2007-}2008-2009-fn diagnostic(bytes: &[u8]) -> String {2010- String::from_utf8_lossy(bytes).trim().to_owned()2011-}2012-2013-2014-struct AnnexBAccessUnitParser {2015- buffer: Vec<u8>,2016-}2017-2018-impl AnnexBAccessUnitParser {2019- fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<u8>>> {2020- self.buffer.extend_from_slice(bytes);2021- ensure!(2022- self.buffer.len() <= MAX_ENCODE_BUFFER,2023- "encoded stream exceeded {MAX_ENCODE_BUFFER} bytes without a frame boundary"2024- );2025-2026- let mut frames = Vec::new();2027- while let Some(first) = find_h264_aud(&self.buffer, 0) {2028- if first > 0 {2029- self.buffer.drain(..first);2030- }2031- let Some(next) = find_h264_aud(&self.buffer, 4) else {2032- break;2033- };2034- let frame: Vec<u8> = self.buffer.drain(..next).collect();2035- if !frame.is_empty() {2036- frames.push(frame);2037- }2038- }2039- Ok(frames)2040- }2041-}2042-2043-fn find_h264_aud(data: &[u8], start: usize) -> Option<usize> {2044- let mut index = start;2045- while index + 4 <= data.len() {2046- let nal_offset = if data.get(index..index + 4) == Some(&[0, 0, 0, 1]) {2047- Some(index + 4)2048- } else if data.get(index..index + 3) == Some(&[0, 0, 1]) {2049- Some(index + 3)2050- } else {2051- None2052- };2053- if let Some(nal_offset) = nal_offset2054- && data.get(nal_offset).is_some_and(|byte| byte & 0x1f == 9)2055- {2056- return Some(index);2057- }2058- index += 1;2059- }2060- None2061-}2062-2063-fn is_h264_keyframe(data: &[u8]) -> bool {2064- h264_nal_types(data).any(|nal_type| nal_type == 5)2065-}2066-2067-fn has_h264_codec_config(data: &[u8]) -> bool {2068- let mut has_sps = false;2069- let mut has_pps = false;2070- for nal_type in h264_nal_types(data) {2071- has_sps |= nal_type == 7;2072- has_pps |= nal_type == 8;2073- }2074- has_sps && has_pps2075-}2076-2077-fn h264_nal_types(data: &[u8]) -> impl Iterator<Item = u8> + '_ {2078- let mut index = 0;2079- std::iter::from_fn(move || {2080- while index + 4 <= data.len() {2081- let nal_offset = if data.get(index..index + 4) == Some(&[0, 0, 0, 1]) {2082- Some(index + 4)2083- } else if data.get(index..index + 3) == Some(&[0, 0, 1]) {2084- Some(index + 3)2085- } else {2086- None2087- };2088- index += 1;2089- if let Some(nal_offset) = nal_offset2090- && let Some(byte) = data.get(nal_offset)2091- {2092- return Some(byte & 0x1f);2093- }2094- }2095- None2096- })2097-}2098-2099-2100-mod tests {2101- use super::*;2102- use crate::config::{GamescopeEncoderBackend, InputMode, VideoSource};2103- use std::collections::BTreeMap;2104- use std::path::PathBuf;2105-2106- fn gamescope_config() -> HostConfig {2107- HostConfig {2108- identity_path: PathBuf::from("/tmp/host.key"),2109- state_path: PathBuf::from("/tmp/state"),2110- source: VideoSource::GamescopePipewire,2111- width: Some(1280),2112- height: Some(800),2113- framerate: 60,2114- codec: "h264".into(),2115- input_mode: InputMode::Disabled,2116- uinput: None,2117- ffmpeg_path: PathBuf::from("/usr/bin/ffmpeg"),2118- gamescope_pipewire: Some(GamescopePipewireConfig {2119- encoder_backend: GamescopeEncoderBackend::ExternalGstLaunch,2120- xwayland_display: None,2121- node_name: "gamescope".into(),2122- media_class: "Video/Source".into(),2123- match_properties: BTreeMap::from([(2124- "device.bus-path".into(),2125- "pci-0000:04:00.0".into(),2126- )]),2127- pw_dump_path: PathBuf::from("/usr/bin/pw-dump"),2128- gst_launch_path: PathBuf::from("/usr/bin/gst-launch-1.0"),2129- gst_inspect_path: PathBuf::from("/usr/bin/gst-inspect-1.0"),2130- vaapi_encoder: "vah264enc".into(),2131- vaapi_render_node: PathBuf::from("/dev/dri/renderD128"),2132- rate_control: VaapiRateControl::Cbr,2133- bitrate_kbps: Some(12_000),2134- quantizer: None,2135- }),2136- audio: None,2137- }2138- }2139-2140- fn configured_video_mode() -> CaptureVideoMode {2141- CaptureVideoMode {2142- width: 1_280,2143- height: 800,2144- framerate: 60,2145- }2146- }2147-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- 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-2183- 2184- async fn encoder_control_coalesces_latest_state_and_acknowledges_only_configured_idr() {2185- let (control, mut desired, status) = EncoderControl::new(1_280, 800);2186-2187- let superseded_bitrate_revision = control.request_bitrate_kbps(4_000).unwrap();2188- let bitrate_revision = control.request_bitrate_kbps(8_000).unwrap();2189- let superseded_resolution_revision = control.request_resolution(960, 600).unwrap();2190- let resolution_revision = control.request_resolution(640, 400).unwrap();2191- let force_keyframe_revision = control.request_force_keyframe().unwrap();2192-2193- assert!(superseded_bitrate_revision < bitrate_revision);2194- assert!(bitrate_revision < superseded_resolution_revision);2195- assert!(superseded_resolution_revision < resolution_revision);2196- assert!(resolution_revision < force_keyframe_revision);2197- let latest = *desired.borrow_and_update();2198- assert_eq!(latest.revision, force_keyframe_revision);2199- assert_eq!(2200- latest.bitrate,2201- Some(RevisedBitrate {2202- revision: bitrate_revision,2203- kbps: 8_000,2204- })2205- );2206- assert_eq!(2207- latest.resolution,2208- Some(RevisedResolution {2209- revision: resolution_revision,2210- width: 640,2211- height: 400,2212- })2213- );2214- assert_eq!(2215- latest.force_keyframe_revision,2216- Some(force_keyframe_revision)2217- );2218-2219- status.send_modify(|status| {2220- status.latest_revision = latest.revision;2221- status.applied_bitrate_revision = Some(bitrate_revision);2222- status.applied_bitrate_kbps = Some(8_000);2223- status.requested_resolution_revision = Some(resolution_revision);2224- status.applied_resolution_revision = Some(resolution_revision);2225- status.applied_width = Some(640);2226- status.applied_height = Some(400);2227- status.requested_force_keyframe_revision = Some(force_keyframe_revision);2228- });2229- let mut pending = Some(force_keyframe_revision);2230- acknowledge_force_keyframe_if_configured_idr(false, &mut pending, &status);2231- assert_eq!(pending, Some(force_keyframe_revision));2232- assert_eq!(control.status().acknowledged_force_keyframe_revision, None);2233-2234- acknowledge_force_keyframe_if_configured_idr(true, &mut pending, &status);2235- assert_eq!(pending, None);2236- let applied = control2237- .wait_for_bitrate_applied(bitrate_revision, Duration::from_millis(10))2238- .await2239- .unwrap();2240- assert_eq!(applied.applied_bitrate_kbps, Some(8_000));2241- let applied_resolution = control2242- .wait_for_resolution_applied(resolution_revision, 640, 400, Duration::from_millis(10))2243- .await2244- .unwrap();2245- assert_eq!(applied_resolution.applied_width, Some(640));2246- assert_eq!(applied_resolution.applied_height, Some(400));2247- assert!(2248- control2249- .wait_for_resolution_applied(2250- superseded_resolution_revision,2251- 960,2252- 600,2253- Duration::from_millis(10),2254- )2255- .await2256- .unwrap_err()2257- .to_string()2258- .contains("superseded")2259- );2260- let acknowledged = control2261- .wait_for_force_keyframe_acknowledged(2262- force_keyframe_revision,2263- Duration::from_millis(10),2264- )2265- .await2266- .unwrap();2267- assert_eq!(2268- acknowledged.acknowledged_force_keyframe_revision,2269- Some(force_keyframe_revision)2270- );2271- assert!(control.request_bitrate_kbps(0).is_err());2272- assert!(control.request_resolution(0, 400).is_err());2273- assert!(control.request_resolution(641, 400).is_err());2274- assert!(2275- control2276- .request_bitrate_kbps(MAX_ENCODER_BITRATE_KBPS + 1)2277- .is_err()2278- );2279-2280- let (closed_control, _desired, closed_status) = EncoderControl::new(1_280, 800);2281- let closed_revision = closed_control.request_force_keyframe().unwrap();2282- drop(closed_status);2283- assert!(2284- closed_control2285- .wait_for_force_keyframe_acknowledged(closed_revision, Duration::from_millis(10))2286- .await2287- .unwrap_err()2288- .to_string()2289- .contains("status closed")2290- );2291- }2292-2293- 2294- async fn recovery_waiter_accepts_a_newer_configured_idr_revision() {2295- let (control, _desired, status) = EncoderControl::new(1_280, 800);2296- let recovery_revision = control.request_force_keyframe().unwrap();2297- let newer_revision = control.request_force_keyframe().unwrap();2298- assert!(newer_revision > recovery_revision);2299- status.send_modify(|status| {2300- status.requested_force_keyframe_revision = Some(newer_revision);2301- status.acknowledged_force_keyframe_revision = Some(newer_revision);2302- });2303-2304- let acknowledged = control2305- .wait_for_recovery_keyframe_acknowledged(recovery_revision, Duration::from_millis(10))2306- .await2307- .unwrap();2308- assert_eq!(2309- acknowledged.acknowledged_force_keyframe_revision,2310- Some(newer_revision)2311- );2312- assert!(2313- control2314- .wait_for_force_keyframe_acknowledged(recovery_revision, Duration::from_millis(10),)2315- .await2316- .unwrap_err()2317- .to_string()2318- .contains("superseded")2319- );2320- }2321-2322- 2323- 2324- 2325- async fn in_process_gstreamer_x264_smoke() {2326- let description = "videotestsrc is-live=true pattern=ball \2327- ! queue max-size-buffers=1 max-size-bytes=0 max-size-time=0 leaky=downstream \2328- ! videoconvert ! videoscale \2329- ! capsfilter name=sigil_scale_caps caps=video/x-raw,width=320,height=180,framerate=30/1 caps-change-mode=delayed \2330- ! x264enc name=sigil_encoder bitrate=2000 tune=zerolatency speed-preset=ultrafast key-int-max=15 bframes=0 byte-stream=true aud=true \2331- ! h264parse config-interval=-1 \2332- ! video/x-h264,stream-format=byte-stream,alignment=au \2333- ! appsink name=sigil_sink max-buffers=1 drop=false sync=false"2334- .to_owned();2335- let mut source =2336- spawn_in_process_pipeline(description, SessionClock::start(), 15, None, None, 320, 180)2337- .unwrap();2338- let control = source2339- .encoder_control2340- .clone()2341- .expect("x264 source has no encoder control");2342-2343- tokio::time::timeout(Duration::from_secs(10), async {2344- loop {2345- source.frames.changed().await.unwrap();2346- if source2347- .frames2348- .borrow()2349- .as_ref()2350- .is_some_and(|frame| frame.keyframe && frame.codec_config)2351- {2352- break;2353- }2354- }2355- })2356- .await2357- .expect("x264 did not publish an initial configured IDR");2358-2359- let bitrate_revision = control.request_bitrate_kbps(1_500).unwrap();2360- let force_keyframe_revision = control.request_force_keyframe().unwrap();2361- let bitrate_status = control2362- .wait_for_bitrate_applied(bitrate_revision, Duration::from_secs(10))2363- .await2364- .expect("x264 did not apply the bitrate revision");2365- assert_eq!(bitrate_status.applied_bitrate_kbps, Some(1_500));2366- control2367- .wait_for_force_keyframe_acknowledged(force_keyframe_revision, Duration::from_secs(10))2368- .await2369- .expect("x264 did not acknowledge a configured forced IDR");2370- let before_resolution_sequence = source2371- .current_gop2372- .borrow()2373- .as_ref()2374- .and_then(|gop| gop.frames.first())2375- .map(|frame| frame.sequence)2376- .expect("x264 has no configured GOP before resolution transition");2377-2378- let reduced_revision = control.request_resolution(256, 144).unwrap();2379- control2380- .wait_for_resolution_applied(reduced_revision, 256, 144, Duration::from_secs(10))2381- .await2382- .expect("x264 did not apply the reduced resolution");2383- let reduced = source2384- .current_gop2385- .borrow()2386- .as_ref()2387- .and_then(|gop| gop.frames.first())2388- .cloned()2389- .expect("x264 has no reduced configured GOP");2390- assert_eq!((reduced.width, reduced.height), (256, 144));2391- assert!(reduced.keyframe && reduced.codec_config && reduced.discontinuity);2392- assert!(reduced.sequence > before_resolution_sequence);2393-2394- let restored_revision = control.request_resolution(320, 180).unwrap();2395- control2396- .wait_for_resolution_applied(restored_revision, 320, 180, Duration::from_secs(10))2397- .await2398- .expect("x264 did not restore the native resolution");2399- let restored = source2400- .current_gop2401- .borrow()2402- .as_ref()2403- .and_then(|gop| gop.frames.first())2404- .cloned()2405- .expect("x264 has no restored configured GOP");2406- assert_eq!((restored.width, restored.height), (320, 180));2407- assert!(restored.keyframe && restored.codec_config && restored.discontinuity);2408- assert!(restored.sequence > reduced.sequence);2409-2410- {2411- let current_gop = source.current_gop.borrow();2412- let gop = current_gop2413- .as_ref()2414- .expect("x264 did not retain a current GOP");2415- assert!(gop.frames[0].keyframe && gop.frames[0].codec_config);2416- assert!(gop.frames.len() <= 15);2417- }2418- let EncodedSource {2419- frames,2420- current_gop,2421- task,2422- ..2423- } = source;2424- drop(frames);2425- drop(current_gop);2426- tokio::time::timeout(Duration::from_secs(2), task)2427- .await2428- .expect("x264 pipeline did not stop after its receivers closed")2429- .expect("x264 pipeline task panicked")2430- .expect("x264 pipeline returned an error");2431- }2432-2433- 2434- 2435- fn in_process_pipeline_drops_only_before_encoding() {2436- let mut config = gamescope_config();2437- config.gamescope_pipewire.as_mut().unwrap().encoder_backend =2438- GamescopeEncoderBackend::InProcessGstreamer;2439- let description =2440- gamescope_in_process_pipeline_description(&config, "1234", configured_video_mode())2441- .unwrap();2442-2443- assert!(description.contains(2444- "queue max-size-buffers=1 max-size-bytes=0 max-size-time=0 leaky=downstream"2445- ));2446- assert!(description.contains(2447- "capsfilter name=sigil_scale_caps caps=video/x-raw,format=NV12,width=1280,height=800,framerate=60/1 caps-change-mode=delayed"2448- ));2449- assert!(2450- description.contains("appsink name=sigil_sink max-buffers=1 drop=false sync=false")2451- );2452- assert!(!description.contains("appsink name=sigil_sink max-buffers=1 drop=true"));2453- }2454-2455- 2456- fn splits_access_units_on_aud() {2457- let first = [0, 0, 0, 1, 9, 0x10, 0, 0, 1, 5, 1, 2, 3];2458- let second = [0, 0, 0, 1, 9, 0x10, 0, 0, 1, 1, 4, 5];2459- let third = [0, 0, 1, 9, 0x10, 0, 0, 1, 1, 6, 7];2460- let mut parser = AnnexBAccessUnitParser::default();2461-2462- let mut bytes = Vec::new();2463- bytes.extend_from_slice(&first);2464- bytes.extend_from_slice(&second);2465- bytes.extend_from_slice(&third);2466- let frames = parser.push(&bytes).unwrap();2467-2468- assert_eq!(frames, vec![first.to_vec(), second.to_vec()]);2469- }2470-2471- 2472- fn detects_h264_keyframes() {2473- assert!(is_h264_keyframe(&[0, 0, 0, 1, 5, 1, 2]));2474- assert!(!is_h264_keyframe(&[0, 0, 1, 7, 1, 2]));2475- assert!(!is_h264_keyframe(&[0, 0, 0, 1, 1, 1, 2]));2476- }2477-2478- 2479- fn codec_config_requires_sps_and_pps() {2480- assert!(has_h264_codec_config(&[2481- 0, 0, 0, 1, 7, 1, 2, 0, 0, 1, 8, 3, 42482- ]));2483- assert!(!has_h264_codec_config(&[0, 0, 1, 7, 1, 2]));2484- assert!(!has_h264_codec_config(&[0, 0, 1, 8, 1, 2]));2485- }2486-2487- 2488- fn resolution_transition_suppresses_until_exact_configured_target_idr() {2489- let transition = PendingResolutionTransition {2490- target: RevisedResolution {2491- revision: 7,2492- width: 960,2493- height: 600,2494- },2495- deadline: Instant::now() + Duration::from_secs(1),2496- };2497- assert!(transition.deadline > Instant::now());2498- assert_eq!(2499- classify_resolution_sample((1280, 800), Some(transition), (1280, 800), true).unwrap(),2500- None2501- );2502- assert_eq!(2503- classify_resolution_sample((1280, 800), Some(transition), (960, 600), false).unwrap(),2504- None2505- );2506- assert_eq!(2507- classify_resolution_sample((1280, 800), Some(transition), (960, 600), true).unwrap(),2508- Some(true)2509- );2510- assert_eq!(2511- classify_resolution_sample((960, 600), None, (960, 600), false).unwrap(),2512- Some(false)2513- );2514- assert!(classify_resolution_sample((960, 600), None, (1280, 800), true).is_err());2515- }2516-2517- 2518- fn publisher_carries_exact_resolution_and_discontinuity_into_new_gop() {2519- let (frame_sender, frame_receiver) = watch::channel(None);2520- let (current_gop_sender, current_gop_receiver) = watch::channel(None);2521- let mut publisher = AccessUnitPublisher::new(2522- SessionClock::start(),2523- &frame_sender,2524- ¤t_gop_sender,2525- 30,2526- 1280,2527- 800,2528- );2529- let configured_idr = vec![0, 0, 0, 1, 7, 1, 0, 0, 0, 1, 8, 1, 0, 0, 0, 1, 5, 1];2530-2531- assert!(2532- publisher2533- .publish_with_metadata(configured_idr, 960, 600, true)2534- .unwrap()2535- );2536- let frame = frame_receiver.borrow().as_ref().unwrap().clone();2537- assert_eq!((frame.width, frame.height), (960, 600));2538- assert!(frame.discontinuity);2539- let current_gop = current_gop_receiver.borrow();2540- let first = ¤t_gop.as_ref().unwrap().frames[0];2541- assert_eq!((first.width, first.height), (960, 600));2542- assert!(first.keyframe && first.codec_config && first.discontinuity);2543- }2544-2545- 2546- fn current_gop_retains_only_a_bounded_contiguous_decodable_chain() {2547- let (frame_sender, frame_receiver) = watch::channel(None);2548- let (current_gop_sender, current_gop_receiver) = watch::channel(None);2549- let frame = |sequence, keyframe, codec_config, payload_len| EncodedFrame {2550- sequence,2551- width: 1280,2552- height: 800,2553- capture_timestamp_micros: sequence,2554- presentation_timestamp_micros: sequence as i64,2555- observed_at: Instant::now(),2556- keyframe,2557- codec_config,2558- discontinuity: false,2559- data: Arc::from(vec![sequence as u8; payload_len]),2560- };2561-2562- for encoded in [2563- frame(10, true, true, 1),2564- frame(11, false, false, 1),2565- frame(12, false, false, 1),2566- ] {2567- publish_encoded_frame(&frame_sender, ¤t_gop_sender, encoded, 4, 8);2568- }2569-2570- assert_eq!(frame_receiver.borrow().as_ref().unwrap().sequence, 12);2571- assert_eq!(2572- current_gop_receiver2573- .borrow()2574- .as_ref()2575- .unwrap()2576- .frames2577- .iter()2578- .map(|frame| frame.sequence)2579- .collect::<Vec<_>>(),2580- vec![10, 11, 12]2581- );2582-2583- publish_encoded_frame(2584- &frame_sender,2585- ¤t_gop_sender,2586- frame(40, true, true, 1),2587- 4,2588- 8,2589- );2590- assert_eq!(2591- current_gop_receiver.borrow().as_ref().unwrap().frames[0].sequence,2592- 402593- );2594-2595- publish_encoded_frame(2596- &frame_sender,2597- ¤t_gop_sender,2598- frame(42, false, false, 1),2599- 4,2600- 8,2601- );2602- assert!(current_gop_receiver.borrow().is_none());2603-2604- publish_encoded_frame(2605- &frame_sender,2606- ¤t_gop_sender,2607- frame(50, true, true, 3),2608- 4,2609- 4,2610- );2611- publish_encoded_frame(2612- &frame_sender,2613- ¤t_gop_sender,2614- frame(51, false, false, 2),2615- 4,2616- 4,2617- );2618- assert!(current_gop_receiver.borrow().is_none());2619-2620- for encoded in [2621- frame(60, true, true, 1),2622- frame(61, false, false, 1),2623- frame(62, false, false, 1),2624- ] {2625- publish_encoded_frame(&frame_sender, ¤t_gop_sender, encoded, 2, 8);2626- }2627- assert!(current_gop_receiver.borrow().is_none());2628- }2629-2630- 2631- fn builds_bounded_low_latency_gamescope_pipeline() {2632- let config = gamescope_config();2633- let args: Vec<String> = gamescope_pipeline_args(&config, "1234", configured_video_mode())2634- .unwrap()2635- .into_iter()2636- .map(|arg| arg.into_string().unwrap())2637- .collect();2638-2639- assert_eq!(args[0], "--quiet");2640- assert!(2641- args.windows(2)2642- .any(|args| { args == ["pipewiresrc".to_owned(), "do-timestamp=true".to_owned()] })2643- );2644- assert!(args.contains(&"target-object=1234".to_owned()));2645- assert!(args.contains(&"min-buffers=1".to_owned()));2646- assert!(args.contains(&"max-buffers=4".to_owned()));2647- assert!(args.contains(&"use-bufferpool=false".to_owned()));2648- assert!(args.contains(&"max-size-buffers=1".to_owned()));2649- assert!(args.contains(&"leaky=downstream".to_owned()));2650- assert!(2651- args.windows(2)2652- .any(|args| { args == ["videorate".to_owned(), "drop-only=true".to_owned()] })2653- );2654- assert!(2655- args.contains(2656- &"video/x-raw,format=NV12,width=1280,height=800,framerate=60/1".to_owned()2657- )2658- );2659- assert!(args.contains(&"vah264enc".to_owned()));2660- assert!(args.contains(&"bitrate=12000".to_owned()));2661- assert!(args.contains(&"rate-control=cbr".to_owned()));2662- assert!(args.contains(&"target-usage=7".to_owned()));2663- assert!(args.contains(&"key-int-max=30".to_owned()));2664- assert!(args.contains(&"b-frames=0".to_owned()));2665- assert!(args.contains(&"aud=true".to_owned()));2666- assert!(args.contains(&"config-interval=-1".to_owned()));2667- assert!(args.contains(&"video/x-h264,stream-format=byte-stream,alignment=au".to_owned()));2668- assert!(args.ends_with(&[2669- "fdsink".to_owned(),2670- "fd=1".to_owned(),2671- "sync=false".to_owned()2672- ]));2673- let videorate = args.iter().position(|arg| arg == "videorate").unwrap();2674- let queue = args.iter().position(|arg| arg == "queue").unwrap();2675- let videoconvert = args.iter().position(|arg| arg == "videoconvert").unwrap();2676- let videoscale = args.iter().position(|arg| arg == "videoscale").unwrap();2677- assert!(queue < videorate && videorate < videoconvert && videoconvert < videoscale);2678- assert!(gamescope_pipeline_args(&config, "gamescope", configured_video_mode()).is_err());2679- }2680-2681- 2682- fn builds_pipeline_for_resolved_non_fixture_mode() {2683- let config = gamescope_config();2684- let mode = CaptureVideoMode {2685- width: 1_920,2686- height: 1_080,2687- framerate: 72,2688- };2689- let args: Vec<String> = gamescope_pipeline_args(&config, "1234", mode)2690- .unwrap()2691- .into_iter()2692- .map(|arg| arg.into_string().unwrap())2693- .collect();2694-2695- assert!(2696- args.contains(2697- &"video/x-raw,format=NV12,width=1920,height=1080,framerate=72/1".to_owned()2698- )2699- );2700- assert!(args.contains(&"max-rate=72".to_owned()));2701- assert!(args.contains(&"key-int-max=36".to_owned()));2702- }2703-2704- 2705- fn recovery_gop_is_bounded_to_half_a_second() {2706- assert_eq!(interactive_gop_frames(60), 30);2707- assert_eq!(interactive_gop_frames(59), 30);2708- assert_eq!(interactive_gop_frames(1), 1);2709- }2710-2711- 2712- fn builds_explicit_low_power_cqp_pipeline() {2713- let mut config = gamescope_config();2714- let pipewire = config.gamescope_pipewire.as_mut().unwrap();2715- pipewire.vaapi_encoder = "vah264lpenc".into();2716- pipewire.rate_control = VaapiRateControl::Cqp;2717- pipewire.bitrate_kbps = None;2718- pipewire.quantizer = Some(24);2719- config.validate().unwrap();2720-2721- let args: Vec<String> = gamescope_pipeline_args(&config, "1234", configured_video_mode())2722- .unwrap()2723- .into_iter()2724- .map(|arg| arg.into_string().unwrap())2725- .collect();2726- assert!(args.contains(&"vah264lpenc".to_owned()));2727- assert!(args.contains(&"rate-control=cqp".to_owned()));2728- assert!(args.contains(&"qpi=24".to_owned()));2729- assert!(args.contains(&"qpp=24".to_owned()));2730- assert!(!args.iter().any(|arg| arg.starts_with("bitrate=")));2731- }2732-2733- 2734- fn resolves_exactly_one_pipewire_node_by_properties() {2735- let config = gamescope_config();2736- let dump = br#"[2737- {"type":"PipeWire:Interface:Node","info":{"props":{2738- "node.name":"gamescope","media.class":"Video/Source",2739- "device.bus-path":"pci-0000:04:00.0","object.serial":7312740- },"params":{"EnumFormat":[2741- {"mediaType":"video","mediaSubtype":"raw","format":"BGRx",2742- "size":{"width":2560,"height":1600}},2743- {"mediaType":"video","mediaSubtype":"raw","format":"NV12",2744- "size":{"width":2560,"height":1600}}2745- ]}}},2746- {"type":"PipeWire:Interface:Node","info":{"props":{2747- "node.name":"gamescope","media.class":"Audio/Source",2748- "device.bus-path":"pci-0000:04:00.0","object.serial":7322749- }}}2750- ]"#;2751- assert_eq!(2752- resolve_pipewire_node(dump, &config).unwrap(),2753- GamescopePreflight {2754- target_object: "731".into(),2755- pointer_surface_dimensions: PointerSurfaceDimensions::new(2_560, 1_600).unwrap(),2756- video_mode: configured_video_mode(),2757- }2758- );2759-2760- let duplicate = br#"[2761- {"type":"PipeWire:Interface:Node","info":{"props":{2762- "node.name":"gamescope","media.class":"Video/Source",2763- "device.bus-path":"pci-0000:04:00.0","object.serial":7312764- }}},2765- {"type":"PipeWire:Interface:Node","info":{"props":{2766- "node.name":"gamescope","media.class":"Video/Source",2767- "device.bus-path":"pci-0000:04:00.0","object.serial":"732"2768- }}}2769- ]"#;2770- assert!(resolve_pipewire_node(duplicate, &config).is_err());2771-2772- let mut native_config = config.clone();2773- native_config.width = None;2774- native_config.height = None;2775- let native = resolve_pipewire_node(dump, &native_config).unwrap();2776- assert_eq!(2777- native.video_mode,2778- CaptureVideoMode {2779- width: 2_560,2780- height: 1_600,2781- framerate: 60,2782- }2783- );2784- assert_eq!(native.pointer_surface_dimensions.width, 2_560);2785- assert_eq!(native.pointer_surface_dimensions.height, 1_600);2786-2787- let mut distorted = config.clone();2788- distorted.width = Some(1_920);2789- distorted.height = Some(1_080);2790- assert!(2791- resolve_pipewire_node(dump, &distorted)2792- .unwrap_err()2793- .to_string()2794- .contains("must preserve native 2560x1600 aspect ratio")2795- );2796-2797- let portrait_dump = std::str::from_utf8(dump)2798- .unwrap()2799- .replace("2560", "WIDTH")2800- .replace("1600", "2560")2801- .replace("WIDTH", "1600");2802- let portrait = resolve_pipewire_node(portrait_dump.as_bytes(), &native_config).unwrap();2803- assert_eq!(2804- (portrait.video_mode.width, portrait.video_mode.height),2805- (1_600, 2_560)2806- );2807- }2808-2809- 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-2840- 2841- fn resolves_pinned_upstream_gamescope_pipewire_contract() {2842- // Sanitized from the standard interface emitted by Valve Gamescope2843- // commit 17baf4abd1ab3353fb705e4d0d023f84e870f7e8, src/pipewire.cpp2844- // blob 76b3ea8cc8ff01c6635498cbafe38130e33215f2. This fixture proves2845- // interface compatibility; it is not SteamOS hardware evidence.2846- let dump = include_bytes!("../tests/fixtures/upstream-gamescope-pipewire-contract.json");2847- let mut config = gamescope_config();2848- config.width = None;2849- config.height = None;2850- config2851- .gamescope_pipewire2852- .as_mut()2853- .unwrap()2854- .match_properties2855- .clear();2856-2857- let resolved = resolve_pipewire_node(dump, &config).unwrap();2858- assert_eq!(resolved.target_object, "8842");2859- assert_eq!(2860- resolved.pointer_surface_dimensions,2861- PointerSurfaceDimensions::new(1_920, 1_080).unwrap()2862- );2863- assert_eq!(2864- resolved.video_mode,2865- CaptureVideoMode {2866- width: 1_920,2867- height: 1_080,2868- framerate: 60,2869- }2870- );2871-2872- let args: Vec<String> =2873- gamescope_pipeline_args(&config, &resolved.target_object, resolved.video_mode)2874- .unwrap()2875- .into_iter()2876- .map(|arg| arg.into_string().unwrap())2877- .collect();2878- assert!(args.contains(&"target-object=8842".to_owned()));2879- assert!(2880- args.contains(2881- &"video/x-raw,format=NV12,width=1920,height=1080,framerate=60/1".to_owned()2882- )2883- );2884- assert!(args.contains(&"max-rate=60".to_owned()));2885- }2886-2887- 2888- fn rejects_ambiguous_or_unbounded_gamescope_native_size() {2889- let config = gamescope_config();2890- let dump = br#"[{"type":"PipeWire:Interface:Node","info":{"props":{2891- "node.name":"gamescope","media.class":"Video/Source",2892- "device.bus-path":"pci-0000:04:00.0","object.serial":7312893- },"params":{"EnumFormat":[2894- {"mediaType":"video","mediaSubtype":"raw","size":{"width":2560,"height":1600}},2895- {"mediaType":"video","mediaSubtype":"raw","size":{"width":1280,"height":800}}2896- ]}}}]"#;2897- assert!(resolve_pipewire_node(dump, &config).is_err());2898-2899- let unbounded = std::str::from_utf8(dump)2900- .unwrap()2901- .replace("2560", "7681")2902- .replace(2903- r#",{"mediaType":"video","mediaSubtype":"raw","size":{"width":1280,"height":800}}"#,2904- "",2905- );2906- assert!(resolve_pipewire_node(unbounded.as_bytes(), &config).is_err());2907-2908- let mut native_config = config.clone();2909- native_config.width = None;2910- native_config.height = None;2911- let odd = br#"[{"type":"PipeWire:Interface:Node","info":{"props":{2912- "node.name":"gamescope","media.class":"Video/Source",2913- "device.bus-path":"pci-0000:04:00.0","object.serial":7312914- },"params":{"EnumFormat":[2915- {"mediaType":"video","mediaSubtype":"raw","size":{"width":1365,"height":768}}2916- ]}}}]"#;2917- let error = resolve_pipewire_node(odd, &native_config).unwrap_err();2918- assert!(error.to_string().contains("must be even"), "{error:#}");2919- }2920-2921- 2922- fn requires_encoder_to_report_configured_render_node() {2923- let config = gamescope_config();2924- let pipewire = config.gamescope_pipewire.as_ref().unwrap();2925- let inspect = br#"2926-Element Properties:2927- device-path : The DRM device path used for VA operation2928- flags: readable2929- String. Default: "/dev/dri/renderD128"2930- aud : Insert an AU delimiter for each frame2931- flags: readable, writable2932- b-frames : Number of B-frames2933- flags: readable, writable2934- bitrate : Target bitrate2935- flags: readable, writable2936- key-int-max : Maximum keyframe distance2937- flags: readable, writable2938- rate-control : Rate control mode2939- flags: readable, writable2940- Enum. Default: cbr2941- (2): cbr - Constant Bitrate2942- ref-frames : Number of reference frames2943- flags: readable, writable2944- target-usage : Speed and quality tradeoff2945- flags: readable, writable2946-"#;2947- validate_encoder_inspection(inspect, pipewire).unwrap();2948-2949- let index = inspect2950- .windows(b"renderD128".len())2951- .position(|window| window == b"renderD128")2952- .unwrap();2953- let mut wrong = inspect.to_vec();2954- wrong[index..index + b"renderD128".len()].copy_from_slice(b"renderD129");2955- assert!(validate_encoder_inspection(&wrong, pipewire).is_err());2956-2957- let mut cqp = gamescope_config();2958- let pipewire = cqp.gamescope_pipewire.as_mut().unwrap();2959- pipewire.vaapi_encoder = "vah264lpenc".into();2960- pipewire.rate_control = VaapiRateControl::Cqp;2961- pipewire.bitrate_kbps = None;2962- pipewire.quantizer = Some(24);2963- let cqp_inspect = String::from_utf8_lossy(inspect)2964- .replace("Default: cbr", "Default: cqp")2965- .replace("(2): cbr", "(16): cqp")2966- .replace(2967- " ref-frames",2968- " qpi : I-frame quantizer\n flags: readable, writable\n qpp : P-frame quantizer\n flags: readable, writable\n ref-frames",2969- );2970- validate_encoder_inspection(cqp_inspect.as_bytes(), pipewire).unwrap();2971- }2972-}@@ -0,0 +1,74 @@
1+use anyhow::{Result, ensure};2+3+use super::MAX_ENCODE_BUFFER;4+5+6+pub(super) struct AnnexBAccessUnitParser {7+ buffer: Vec<u8>,8+}9+10+impl AnnexBAccessUnitParser {11+ pub(super) fn push(&mut self, bytes: &[u8]) -> Result<Vec<Vec<u8>>> {12+ self.buffer.extend_from_slice(bytes);13+ ensure!(14+ self.buffer.len() <= MAX_ENCODE_BUFFER,15+ "encoded stream exceeded {MAX_ENCODE_BUFFER} bytes without a frame boundary"16+ );17+18+ let mut frames = Vec::new();19+ while let Some(first) = find_h264_aud(&self.buffer, 0) {20+ if first > 0 {21+ self.buffer.drain(..first);22+ }23+ let Some(next) = find_h264_aud(&self.buffer, 4) else {24+ break;25+ };26+ let frame: Vec<u8> = self.buffer.drain(..next).collect();27+ if !frame.is_empty() {28+ frames.push(frame);29+ }30+ }31+ Ok(frames)32+ }33+}34+35+fn find_h264_aud(data: &[u8], start: usize) -> Option<usize> {36+ let mut index = start;37+ while index + 4 <= data.len() {38+ let nal_offset = if data.get(index..index + 4) == Some(&[0, 0, 0, 1]) {39+ Some(index + 4)40+ } else if data.get(index..index + 3) == Some(&[0, 0, 1]) {41+ Some(index + 3)42+ } else {43+ None44+ };45+ if let Some(nal_offset) = nal_offset46+ && data.get(nal_offset).is_some_and(|byte| byte & 0x1f == 9)47+ {48+ return Some(index);49+ }50+ index += 1;51+ }52+ None53+}54+55+56+mod tests {57+ use super::*;58+59+ 60+ fn splits_access_units_on_aud() {61+ let first = [0, 0, 0, 1, 9, 0x10, 0, 0, 1, 5, 1, 2, 3];62+ let second = [0, 0, 0, 1, 9, 0x10, 0, 0, 1, 1, 4, 5];63+ let third = [0, 0, 1, 9, 0x10, 0, 0, 1, 1, 6, 7];64+ let mut parser = AnnexBAccessUnitParser::default();65+66+ let mut bytes = Vec::new();67+ bytes.extend_from_slice(&first);68+ bytes.extend_from_slice(&second);69+ bytes.extend_from_slice(&third);70+ let frames = parser.push(&bytes).unwrap();71+72+ assert_eq!(frames, vec![first.to_vec(), second.to_vec()]);73+ }74+}@@ -0,0 +1,355 @@
1+use std::ffi::OsString;2+use std::process::Stdio;3+4+use anyhow::{Context, Result, bail, ensure};5+use tokio::io::{AsyncRead, AsyncReadExt};6+use tokio::process::Command;7+use tokio::sync::watch;8+use tracing::debug;9+10+use crate::clock::SessionClock;11+use crate::config::{HostConfig, VaapiRateControl};12+13+use super::annexb::AnnexBAccessUnitParser;14+use super::{15+ AccessUnitPublisher, CaptureVideoMode, EncodedFrame, EncodedGop, EncodedSource,16+ GamescopePreflight, gamescope_config, interactive_gop_frames, log_stderr_chunks,17+};18+19+pub(super) fn spawn_gamescope_pipewire_with_target(20+ config: HostConfig,21+ session_clock: SessionClock,22+ preflight: GamescopePreflight,23+) -> Result<EncodedSource> {24+ let (sender, receiver) = watch::channel(None);25+ let (current_gop_sender, current_gop) = watch::channel(None);26+ let target_object = preflight.target_object;27+ let video_mode = preflight.video_mode;28+ let task = tokio::spawn(async move {29+ run_gamescope_pipewire(30+ config,31+ session_clock,32+ target_object,33+ video_mode,34+ sender,35+ current_gop_sender,36+ )37+ .await38+ });39+ Ok(EncodedSource {40+ frames: receiver,41+ current_gop,42+ task,43+ pointer_surface_dimensions: Some(preflight.pointer_surface_dimensions),44+ encoder_control: None,45+ })46+}47+48+async fn run_gamescope_pipewire(49+ config: HostConfig,50+ session_clock: SessionClock,51+ target_object: String,52+ video_mode: CaptureVideoMode,53+ sender: watch::Sender<Option<EncodedFrame>>,54+ current_gop_sender: watch::Sender<Option<EncodedGop>>,55+) -> Result<()> {56+ let pipewire = gamescope_config(&config)?;57+ let args = gamescope_pipeline_args(&config, &target_object, video_mode)?;58+ let mut command = Command::new(&pipewire.gst_launch_path);59+ command60+ .args(args)61+ .env("LC_ALL", "C")62+ // This is honored by the legacy VAAPI plugin and harmless for GstVA.63+ // GstVA selection is independently checked against device-path above.64+ .env("GST_VAAPI_DRM_DEVICE", &pipewire.vaapi_render_node)65+ .stdin(Stdio::null())66+ .stdout(Stdio::piped())67+ .stderr(Stdio::piped())68+ .kill_on_drop(true);69+70+ let mut child = command.spawn().with_context(|| {71+ format!(72+ "starting Gamescope pipeline with {}",73+ pipewire.gst_launch_path.display()74+ )75+ })?;76+ let mut stdout = child77+ .stdout78+ .take()79+ .context("GStreamer stdout was not piped")?;80+ let stderr = child81+ .stderr82+ .take()83+ .context("GStreamer stderr was not piped")?;84+ let stderr_task = tokio::spawn(log_stderr_chunks(stderr, "gstreamer"));85+86+ let result = forward_annex_b_stream(87+ session_clock,88+ &mut stdout,89+ &sender,90+ ¤t_gop_sender,91+ interactive_gop_frames(video_mode.framerate) as usize,92+ video_mode.width,93+ video_mode.height,94+ )95+ .await;96+ let _ = child.kill().await;97+ let exit_status = child.wait().await.ok();98+ stderr_task.abort();99+100+ match result {101+ Ok(()) => Ok(()),102+ Err(error) => {103+ if let Some(status) = exit_status {104+ Err(error).with_context(|| format!("GStreamer pipeline exited with {status}"))105+ } else {106+ Err(error)107+ }108+ }109+ }110+}111+112+pub(super) async fn forward_annex_b_stream<R>(113+ session_clock: SessionClock,114+ stdout: &mut R,115+ sender: &watch::Sender<Option<EncodedFrame>>,116+ current_gop_sender: &watch::Sender<Option<EncodedGop>>,117+ max_gop_frames: usize,118+ width: u16,119+ height: u16,120+) -> Result<()>121+where122+ R: AsyncRead + Unpin,123+{124+ let mut publisher = AccessUnitPublisher::new(125+ session_clock,126+ sender,127+ current_gop_sender,128+ max_gop_frames,129+ width,130+ height,131+ );132+ let mut parser = AnnexBAccessUnitParser::default();133+ let mut chunk = [0_u8; 16 * 1024];134+135+ loop {136+ let count = stdout137+ .read(&mut chunk)138+ .await139+ .context("reading encoded source output")?;140+ if count == 0 {141+ bail!("encoded source ended unexpectedly");142+ }143+144+ for access_unit in parser.push(&chunk[..count])? {145+ publisher.publish(access_unit)?;146+ if publisher.receivers_closed() {147+ debug!("encoded source has no receivers");148+ return Ok(());149+ }150+ }151+ }152+}153+154+pub(super) fn gamescope_pipeline_args(155+ config: &HostConfig,156+ target_object: &str,157+ video_mode: CaptureVideoMode,158+) -> Result<Vec<OsString>> {159+ let pipewire = gamescope_config(config)?;160+ ensure!(161+ !target_object.is_empty()162+ && target_object.len() <= 32163+ && target_object.bytes().all(|byte| byte.is_ascii_digit()),164+ "resolved PipeWire object.serial is invalid"165+ );166+ let caps = format!(167+ "video/x-raw,format=NV12,width={},height={},framerate={}/1",168+ video_mode.width, video_mode.height, video_mode.framerate169+ );170+ let encoded_caps = "video/x-h264,stream-format=byte-stream,alignment=au";171+ let mut args = vec![172+ "--quiet".to_owned(),173+ "pipewiresrc".to_owned(),174+ "do-timestamp=true".to_owned(),175+ // Keep Gamescope's negotiated producer pool bounded at its preferred176+ // four buffers, but copy each delivered frame into GStreamer's own177+ // buffer before downstream conversion/encoding can retain it. This178+ // returns producer buffers promptly instead of starving Gamescope at179+ // vblank. `use-bufferpool=false` is the supported replacement for the180+ // deprecated `always-copy=true` spelling.181+ "min-buffers=1".to_owned(),182+ "max-buffers=4".to_owned(),183+ "use-bufferpool=false".to_owned(),184+ format!("target-object={target_object}"),185+ "!".to_owned(),186+ "queue".to_owned(),187+ "max-size-buffers=1".to_owned(),188+ "max-size-bytes=0".to_owned(),189+ "max-size-time=0".to_owned(),190+ "leaky=downstream".to_owned(),191+ "!".to_owned(),192+ "videorate".to_owned(),193+ // Gamescope is damage-driven. Never manufacture replacement frames:194+ // doing so can turn an irregular live source into stale catch-up work.195+ "drop-only=true".to_owned(),196+ format!("max-rate={}", video_mode.framerate),197+ "!".to_owned(),198+ "videoconvert".to_owned(),199+ "!".to_owned(),200+ "videoscale".to_owned(),201+ "!".to_owned(),202+ caps,203+ "!".to_owned(),204+ pipewire.vaapi_encoder.clone(),205+ ];206+ args.push(format!(207+ "rate-control={}",208+ match pipewire.rate_control {209+ VaapiRateControl::Cbr => "cbr",210+ VaapiRateControl::Cqp => "cqp",211+ }212+ ));213+ match pipewire.rate_control {214+ VaapiRateControl::Cbr => args.push(format!(215+ "bitrate={}",216+ pipewire217+ .bitrate_kbps218+ .context("CBR bitrate is missing after validation")?219+ )),220+ VaapiRateControl::Cqp => {221+ let quantizer = pipewire222+ .quantizer223+ .context("CQP quantizer is missing after validation")?;224+ args.push(format!("qpi={quantizer}"));225+ args.push(format!("qpp={quantizer}"));226+ }227+ }228+ args.extend([229+ "target-usage=7".to_owned(),230+ format!(231+ "key-int-max={}",232+ interactive_gop_frames(video_mode.framerate)233+ ),234+ "b-frames=0".to_owned(),235+ "ref-frames=1".to_owned(),236+ "aud=true".to_owned(),237+ "!".to_owned(),238+ "h264parse".to_owned(),239+ "config-interval=-1".to_owned(),240+ "!".to_owned(),241+ encoded_caps.to_owned(),242+ "!".to_owned(),243+ "fdsink".to_owned(),244+ "fd=1".to_owned(),245+ // Gamescope's PipeWire timestamps can advance in larger jumps than246+ // the negotiated frame duration. Clocking this terminal sink makes it247+ // wait on those producer timestamps while the one-frame leaky queue248+ // drops fresh motion. Videorate already caps the live stream at the249+ // configured maximum, so drain encoded access units immediately.250+ "sync=false".to_owned(),251+ ]);252+ Ok(args.into_iter().map(OsString::from).collect())253+}254+255+256+mod tests {257+ use super::super::tests::{configured_video_mode, gamescope_config};258+ use super::*;259+260+ 261+ fn builds_bounded_low_latency_gamescope_pipeline() {262+ let config = gamescope_config();263+ let args: Vec<String> = gamescope_pipeline_args(&config, "1234", configured_video_mode())264+ .unwrap()265+ .into_iter()266+ .map(|arg| arg.into_string().unwrap())267+ .collect();268+269+ assert_eq!(args[0], "--quiet");270+ assert!(271+ args.windows(2)272+ .any(|args| { args == ["pipewiresrc".to_owned(), "do-timestamp=true".to_owned()] })273+ );274+ assert!(args.contains(&"target-object=1234".to_owned()));275+ assert!(args.contains(&"min-buffers=1".to_owned()));276+ assert!(args.contains(&"max-buffers=4".to_owned()));277+ assert!(args.contains(&"use-bufferpool=false".to_owned()));278+ assert!(args.contains(&"max-size-buffers=1".to_owned()));279+ assert!(args.contains(&"leaky=downstream".to_owned()));280+ assert!(281+ args.windows(2)282+ .any(|args| { args == ["videorate".to_owned(), "drop-only=true".to_owned()] })283+ );284+ assert!(285+ args.contains(286+ &"video/x-raw,format=NV12,width=1280,height=800,framerate=60/1".to_owned()287+ )288+ );289+ assert!(args.contains(&"vah264enc".to_owned()));290+ assert!(args.contains(&"bitrate=12000".to_owned()));291+ assert!(args.contains(&"rate-control=cbr".to_owned()));292+ assert!(args.contains(&"target-usage=7".to_owned()));293+ assert!(args.contains(&"key-int-max=30".to_owned()));294+ assert!(args.contains(&"b-frames=0".to_owned()));295+ assert!(args.contains(&"aud=true".to_owned()));296+ assert!(args.contains(&"config-interval=-1".to_owned()));297+ assert!(args.contains(&"video/x-h264,stream-format=byte-stream,alignment=au".to_owned()));298+ assert!(args.ends_with(&[299+ "fdsink".to_owned(),300+ "fd=1".to_owned(),301+ "sync=false".to_owned()302+ ]));303+ let videorate = args.iter().position(|arg| arg == "videorate").unwrap();304+ let queue = args.iter().position(|arg| arg == "queue").unwrap();305+ let videoconvert = args.iter().position(|arg| arg == "videoconvert").unwrap();306+ let videoscale = args.iter().position(|arg| arg == "videoscale").unwrap();307+ assert!(queue < videorate && videorate < videoconvert && videoconvert < videoscale);308+ assert!(gamescope_pipeline_args(&config, "gamescope", configured_video_mode()).is_err());309+ }310+311+ 312+ fn builds_pipeline_for_resolved_non_fixture_mode() {313+ let config = gamescope_config();314+ let mode = CaptureVideoMode {315+ width: 1_920,316+ height: 1_080,317+ framerate: 72,318+ };319+ let args: Vec<String> = gamescope_pipeline_args(&config, "1234", mode)320+ .unwrap()321+ .into_iter()322+ .map(|arg| arg.into_string().unwrap())323+ .collect();324+325+ assert!(326+ args.contains(327+ &"video/x-raw,format=NV12,width=1920,height=1080,framerate=72/1".to_owned()328+ )329+ );330+ assert!(args.contains(&"max-rate=72".to_owned()));331+ assert!(args.contains(&"key-int-max=36".to_owned()));332+ }333+334+ 335+ fn builds_explicit_low_power_cqp_pipeline() {336+ let mut config = gamescope_config();337+ let pipewire = config.gamescope_pipewire.as_mut().unwrap();338+ pipewire.vaapi_encoder = "vah264lpenc".into();339+ pipewire.rate_control = VaapiRateControl::Cqp;340+ pipewire.bitrate_kbps = None;341+ pipewire.quantizer = Some(24);342+ config.validate().unwrap();343+344+ let args: Vec<String> = gamescope_pipeline_args(&config, "1234", configured_video_mode())345+ .unwrap()346+ .into_iter()347+ .map(|arg| arg.into_string().unwrap())348+ .collect();349+ assert!(args.contains(&"vah264lpenc".to_owned()));350+ assert!(args.contains(&"rate-control=cqp".to_owned()));351+ assert!(args.contains(&"qpi=24".to_owned()));352+ assert!(args.contains(&"qpp=24".to_owned()));353+ assert!(!args.iter().any(|arg| arg.starts_with("bitrate=")));354+ }355+}@@ -0,0 +1,1197 @@
1+use std::sync::Arc;2+use std::sync::atomic::{AtomicU64, Ordering};3+use std::time::Duration;4+5+use std::time::Instant;6+7+use anyhow::{Context, Result, bail, ensure};8+use tokio::sync::watch;9+10+11+use crate::clock::SessionClock;12+13+use crate::config::{HostConfig, VaapiRateControl};14+15+use sigil_protocol::PointerSurfaceDimensions;16+17+18+use super::{19+ AccessUnitPublisher, CaptureVideoMode, EncodedFrame, EncodedGop, EncodedSource,20+ GamescopePreflight, MAX_ENCODE_BUFFER, gamescope_config, has_h264_codec_config,21+ interactive_gop_frames, is_h264_keyframe,22+};23+24+25+use gstreamer::prelude::ObjectExt;26+27+use tracing::{debug, warn};28+29+30+const RESOLUTION_APPLY_TIMEOUT: Duration = Duration::from_secs(2);31+32+const MAX_ENCODER_BITRATE_KBPS: u32 = 100_000;33+34+35+struct RevisedBitrate {36+ revision: u64,37+ kbps: u32,38+}39+40+41+struct RevisedResolution {42+ revision: u64,43+ width: u16,44+ height: u16,45+}46+47+48+struct EncoderControlDesired {49+ revision: u64,50+ bitrate: Option<RevisedBitrate>,51+ resolution: Option<RevisedResolution>,52+ force_keyframe_revision: Option<u64>,53+}54+55+56+pub struct EncoderControlStatus {57+ pub latest_revision: u64,58+ pub applied_bitrate_revision: Option<u64>,59+ pub applied_bitrate_kbps: Option<u32>,60+ pub requested_resolution_revision: Option<u64>,61+ pub applied_resolution_revision: Option<u64>,62+ pub applied_width: Option<u16>,63+ pub applied_height: Option<u16>,64+ pub requested_force_keyframe_revision: Option<u64>,65+ pub acknowledged_force_keyframe_revision: Option<u64>,66+}67+68+/// Bounded latest-state control for an in-process encoder.69+///70+/// Both directions are Tokio watch channels: callers can update control state71+/// arbitrarily often without building a queue, while the encoder applies only72+/// the latest revision. Revisions are process-monotonic and never wrap.73+74+pub struct EncoderControl {75+ initial_width: u16,76+ initial_height: u16,77+ next_revision: Arc<AtomicU64>,78+ desired: watch::Sender<EncoderControlDesired>,79+ status: watch::Receiver<EncoderControlStatus>,80+}81+82+83+84+85+86+impl EncoderControl {87+ fn new(88+ initial_width: u16,89+ initial_height: u16,90+ ) -> (91+ Self,92+ watch::Receiver<EncoderControlDesired>,93+ watch::Sender<EncoderControlStatus>,94+ ) {95+ let (desired, desired_rx) = watch::channel(EncoderControlDesired::default());96+ let (status_tx, status) = watch::channel(EncoderControlStatus::default());97+ (98+ Self {99+ initial_width,100+ initial_height,101+ next_revision: Arc::new(AtomicU64::new(0)),102+ desired,103+ status,104+ },105+ desired_rx,106+ status_tx,107+ )108+ }109+110+ pub(crate) fn initial_dimensions(&self) -> (u16, u16) {111+ (self.initial_width, self.initial_height)112+ }113+114+ fn next_revision(&self) -> Result<u64> {115+ self.next_revision116+ .fetch_update(Ordering::SeqCst, Ordering::SeqCst, |revision| {117+ revision.checked_add(1)118+ })119+ .map(|revision| revision + 1)120+ .map_err(|_| anyhow::anyhow!("encoder control revision exhausted"))121+ }122+123+ pub fn request_bitrate_kbps(&self, kbps: u32) -> Result<u64> {124+ ensure!(125+ (1..=MAX_ENCODER_BITRATE_KBPS).contains(&kbps),126+ "encoder bitrate must be between 1 and {MAX_ENCODER_BITRATE_KBPS} kbps"127+ );128+ let revision = self.next_revision()?;129+ self.desired.send_modify(|desired| {130+ desired.revision = revision;131+ desired.bitrate = Some(RevisedBitrate { revision, kbps });132+ });133+ Ok(revision)134+ }135+136+ pub fn request_force_keyframe(&self) -> Result<u64> {137+ let revision = self.next_revision()?;138+ self.desired.send_modify(|desired| {139+ desired.revision = revision;140+ desired.force_keyframe_revision = Some(revision);141+ });142+ Ok(revision)143+ }144+145+ pub fn request_resolution(&self, width: u16, height: u16) -> Result<u64> {146+ ensure!(147+ width >= 64 && height >= 64,148+ "encoder dimensions must be at least 64x64"149+ );150+ ensure!(151+ width.is_multiple_of(2) && height.is_multiple_of(2),152+ "H.264 encoder dimensions must be even"153+ );154+ let revision = self.next_revision()?;155+ self.desired.send_modify(|desired| {156+ desired.revision = revision;157+ desired.resolution = Some(RevisedResolution {158+ revision,159+ width,160+ height,161+ });162+ });163+ Ok(revision)164+ }165+166+ pub fn status(&self) -> EncoderControlStatus {167+ *self.status.borrow()168+ }169+170+ /// Wait until the encoder confirms the exact bitrate revision and exposes171+ /// its property readback. A newer applied revision means this request was172+ /// coalesced before application and therefore fails rather than pretending173+ /// the requested revision committed.174+ pub async fn wait_for_bitrate_applied(175+ &self,176+ revision: u64,177+ timeout: Duration,178+ ) -> Result<EncoderControlStatus> {179+ let mut status = self.status.clone();180+ let wait = async {181+ loop {182+ let snapshot = *status.borrow_and_update();183+ match snapshot.applied_bitrate_revision {184+ Some(applied) if applied == revision => return Ok(snapshot),185+ Some(applied) if applied > revision => bail!(186+ "encoder bitrate revision {revision} was superseded by revision {applied}"187+ ),188+ _ => {}189+ }190+ status191+ .changed()192+ .await193+ .context("encoder control status closed before bitrate was applied")?;194+ }195+ };196+ tokio::time::timeout(timeout, wait)197+ .await198+ .with_context(|| format!("timed out waiting for encoder bitrate revision {revision}"))?199+ }200+201+ /// Wait for a forced-keyframe request to be committed by observing a202+ /// keyframe that also carries codec configuration.203+ pub async fn wait_for_force_keyframe_acknowledged(204+ &self,205+ revision: u64,206+ timeout: Duration,207+ ) -> Result<EncoderControlStatus> {208+ let mut status = self.status.clone();209+ let wait = async {210+ loop {211+ let snapshot = *status.borrow_and_update();212+ match snapshot.acknowledged_force_keyframe_revision {213+ Some(acknowledged) if acknowledged == revision => return Ok(snapshot),214+ Some(acknowledged) if acknowledged > revision => bail!(215+ "force-keyframe revision {revision} was superseded by revision {acknowledged}"216+ ),217+ _ => {}218+ }219+ if snapshot220+ .requested_force_keyframe_revision221+ .is_some_and(|requested| requested > revision)222+ {223+ bail!("force-keyframe revision {revision} was coalesced before application");224+ }225+ status.changed().await.context(226+ "encoder control status closed before forced keyframe was acknowledged",227+ )?;228+ }229+ };230+ tokio::time::timeout(timeout, wait)231+ .await232+ .with_context(|| format!("timed out waiting for force-keyframe revision {revision}"))?233+ }234+235+ /// Wait for recovery to reach any configured IDR at or after `revision`.236+ ///237+ /// Recovery requests are deliberately coalesced with later encoder238+ /// controls. Unlike transactional adaptive updates, a newer configured IDR239+ /// still satisfies the decoder barrier established by an older request.240+ pub async fn wait_for_recovery_keyframe_acknowledged(241+ &self,242+ revision: u64,243+ timeout: Duration,244+ ) -> Result<EncoderControlStatus> {245+ let mut status = self.status.clone();246+ let wait = async {247+ loop {248+ let snapshot = *status.borrow_and_update();249+ if snapshot250+ .acknowledged_force_keyframe_revision251+ .is_some_and(|acknowledged| acknowledged >= revision)252+ {253+ return Ok(snapshot);254+ }255+ status.changed().await.context(256+ "encoder control status closed before recovery keyframe was acknowledged",257+ )?;258+ }259+ };260+ tokio::time::timeout(timeout, wait).await.with_context(|| {261+ format!("timed out waiting for recovery keyframe revision {revision}")262+ })?263+ }264+265+ /// Wait until the exact target dimensions emerge from the encoder on an266+ /// independently decodable access unit. A property write or caps event is267+ /// not an application acknowledgement.268+ pub async fn wait_for_resolution_applied(269+ &self,270+ revision: u64,271+ width: u16,272+ height: u16,273+ timeout: Duration,274+ ) -> Result<EncoderControlStatus> {275+ let mut status = self.status.clone();276+ let wait = async {277+ loop {278+ let snapshot = *status.borrow_and_update();279+ match snapshot.applied_resolution_revision {280+ Some(applied) if applied == revision => {281+ ensure!(282+ snapshot.applied_width == Some(width)283+ && snapshot.applied_height == Some(height),284+ "encoder resolution revision {revision} acknowledged {:?}x{:?}, expected {width}x{height}",285+ snapshot.applied_width,286+ snapshot.applied_height287+ );288+ return Ok(snapshot);289+ }290+ Some(applied) if applied > revision => bail!(291+ "encoder resolution revision {revision} was superseded by revision {applied}"292+ ),293+ _ => {}294+ }295+ if snapshot296+ .requested_resolution_revision297+ .is_some_and(|requested| requested > revision)298+ {299+ bail!(300+ "encoder resolution revision {revision} was coalesced before application"301+ );302+ }303+ status304+ .changed()305+ .await306+ .context("encoder control status closed before resolution was applied")?;307+ }308+ };309+ tokio::time::timeout(timeout, wait).await.with_context(|| {310+ format!("timed out waiting for encoder resolution revision {revision}")311+ })?312+ }313+}314+315+316+pub(crate) struct EncoderControlTestHarness {317+ pub control: EncoderControl,318+ pub status: watch::Sender<EncoderControlStatus>,319+ _desired: watch::Receiver<EncoderControlDesired>,320+}321+322+323+impl EncoderControlTestHarness {324+ pub(crate) fn new() -> Self {325+ Self::with_dimensions(1_280, 800)326+ }327+328+ pub(crate) fn with_dimensions(width: u16, height: u16) -> Self {329+ let (control, desired, status) = EncoderControl::new(width, height);330+ Self {331+ control,332+ status,333+ _desired: desired,334+ }335+ }336+337+ pub(crate) fn requested_force_keyframe_revision(&self) -> Option<u64> {338+ self._desired.borrow().force_keyframe_revision339+ }340+}341+342+343+pub(super) fn spawn_gamescope_pipewire_in_process_with_target(344+ config: HostConfig,345+ session_clock: SessionClock,346+ preflight: GamescopePreflight,347+) -> Result<EncodedSource> {348+ let description = gamescope_in_process_pipeline_description(349+ &config,350+ &preflight.target_object,351+ preflight.video_mode,352+ )?;353+ let max_gop_frames = interactive_gop_frames(preflight.video_mode.framerate) as usize;354+ let expected_device_path = Some(355+ gamescope_config(&config)?356+ .vaapi_render_node357+ .to_string_lossy()358+ .into_owned(),359+ );360+ spawn_in_process_pipeline(361+ description,362+ session_clock,363+ max_gop_frames,364+ Some(preflight.pointer_surface_dimensions),365+ expected_device_path,366+ preflight.video_mode.width,367+ preflight.video_mode.height,368+ )369+}370+371+372+fn spawn_in_process_pipeline(373+ description: String,374+ session_clock: SessionClock,375+ max_gop_frames: usize,376+ pointer_surface_dimensions: Option<PointerSurfaceDimensions>,377+ expected_device_path: Option<String>,378+ initial_width: u16,379+ initial_height: u16,380+) -> Result<EncodedSource> {381+ let (sender, receiver) = watch::channel(None);382+ let (current_gop_sender, current_gop) = watch::channel(None);383+ let (control, desired, status) = EncoderControl::new(initial_width, initial_height);384+ let task = tokio::spawn(async move {385+ tokio::task::spawn_blocking(move || {386+ run_in_process_pipeline(387+ &description,388+ session_clock,389+ sender,390+ current_gop_sender,391+ max_gop_frames,392+ desired,393+ status,394+ expected_device_path.as_deref(),395+ initial_width,396+ initial_height,397+ )398+ })399+ .await400+ .context("in-process GStreamer worker panicked")?401+ });402+ Ok(EncodedSource {403+ frames: receiver,404+ current_gop,405+ task,406+ pointer_surface_dimensions,407+ encoder_control: Some(control),408+ })409+}410+411+412+fn gamescope_in_process_pipeline_description(413+ config: &HostConfig,414+ target_object: &str,415+ video_mode: CaptureVideoMode,416+) -> Result<String> {417+ let pipewire = gamescope_config(config)?;418+ validate_pipewire_target_object(target_object)?;419+ ensure!(420+ pipewire.rate_control == VaapiRateControl::Cbr,421+ "in-process GStreamer requires CBR so bitrate can be changed while playing"422+ );423+ let bitrate = pipewire424+ .bitrate_kbps425+ .context("CBR bitrate is missing after validation")?;426+ let raw_caps = format!(427+ "video/x-raw,format=NV12,width={},height={},framerate={}/1",428+ video_mode.width, video_mode.height, video_mode.framerate429+ );430+ Ok(format!(431+ "pipewiresrc do-timestamp=true min-buffers=1 max-buffers=4 use-bufferpool=false target-object={target_object} \432+ ! queue max-size-buffers=1 max-size-bytes=0 max-size-time=0 leaky=downstream \433+ ! videorate drop-only=true max-rate={} \434+ ! videoconvert ! videoscale \435+ ! capsfilter name=sigil_scale_caps caps={raw_caps} caps-change-mode=delayed \436+ ! {} name=sigil_encoder rate-control=cbr bitrate={bitrate} target-usage=7 key-int-max={} b-frames=0 ref-frames=1 aud=true \437+ ! h264parse config-interval=-1 \438+ ! video/x-h264,stream-format=byte-stream,alignment=au \439+ ! appsink name=sigil_sink max-buffers=1 drop=false sync=false",440+ video_mode.framerate,441+ pipewire.vaapi_encoder,442+ interactive_gop_frames(video_mode.framerate),443+ ))444+}445+446+447+fn validate_pipewire_target_object(target_object: &str) -> Result<()> {448+ ensure!(449+ !target_object.is_empty()450+ && target_object.len() <= 32451+ && target_object.bytes().all(|byte| byte.is_ascii_digit()),452+ "resolved PipeWire object.serial is invalid"453+ );454+ Ok(())455+}456+457+458+459+struct PendingResolutionTransition {460+ target: RevisedResolution,461+ deadline: Instant,462+}463+464+465+fn classify_resolution_sample(466+ current: (u16, u16),467+ pending: Option<PendingResolutionTransition>,468+ sample: (u16, u16),469+ configured_idr: bool,470+) -> Result<Option<bool>> {471+ if let Some(pending) = pending {472+ if sample != (pending.target.width, pending.target.height) || !configured_idr {473+ return Ok(None);474+ }475+ return Ok(Some(true));476+ }477+ ensure!(478+ sample == current,479+ "encoder changed resolution from {}x{} to {}x{} without a pending control revision",480+ current.0,481+ current.1,482+ sample.0,483+ sample.1484+ );485+ Ok(Some(false))486+}487+488+489+fn run_in_process_pipeline(490+ description: &str,491+ session_clock: SessionClock,492+ sender: watch::Sender<Option<EncodedFrame>>,493+ current_gop_sender: watch::Sender<Option<EncodedGop>>,494+ max_gop_frames: usize,495+ mut desired: watch::Receiver<EncoderControlDesired>,496+ status: watch::Sender<EncoderControlStatus>,497+ expected_device_path: Option<&str>,498+ initial_width: u16,499+ initial_height: u16,500+) -> Result<()> {501+ use gstreamer as gst;502+ use gstreamer::prelude::*;503+ use gstreamer_app as gst_app;504+ gst::init().context("initializing in-process GStreamer")?;505+ let pipeline = gst::parse::launch(description)506+ .context("parsing in-process GStreamer pipeline")?507+ .downcast::<gst::Pipeline>()508+ .map_err(|_| {509+ anyhow::anyhow!("in-process GStreamer description did not create a pipeline")510+ })?;511+ let encoder = pipeline512+ .by_name("sigil_encoder")513+ .context("in-process GStreamer pipeline has no named encoder")?;514+ let scale_caps = pipeline515+ .by_name("sigil_scale_caps")516+ .context("in-process GStreamer pipeline has no named scale capsfilter")?;517+ let sink = pipeline518+ .by_name("sigil_sink")519+ .context("in-process GStreamer pipeline has no named appsink")?520+ .downcast::<gst_app::AppSink>()521+ .map_err(|_| anyhow::anyhow!("named in-process sink is not an appsink"))?;522+ sink.set_max_buffers(1);523+ // Encoded dependent AUs must not be dropped here: the raw queue before the524+ // encoder is the only latest-frame boundary. This one-buffer sink applies525+ // bounded backpressure until the publishing loop consumes the AU.526+ sink.set_drop(false);527+ sink.set_sync(false);528+ validate_mutable_playing_bitrate(&encoder)?;529+ validate_resolution_capsfilter(&scale_caps)?;530+531+ pipeline532+ .set_state(gst::State::Playing)533+ .context("starting in-process GStreamer pipeline")?;534+ let result = (|| {535+ let (state_change, state, pending) = pipeline.state(gst::ClockTime::from_seconds(5));536+ state_change.context("in-process GStreamer pipeline failed while waiting for PLAYING")?;537+ ensure!(538+ state == gst::State::Playing,539+ "in-process GStreamer pipeline did not reach PLAYING within 5 seconds (state {state:?}, pending {pending:?})"540+ );541+ if let Some(expected_device_path) = expected_device_path {542+ validate_active_encoder_device_path(&encoder, expected_device_path)?;543+ }544+ let bus = pipeline545+ .bus()546+ .context("in-process GStreamer pipeline has no bus")?;547+ let mut publisher = AccessUnitPublisher::new(548+ session_clock,549+ &sender,550+ ¤t_gop_sender,551+ max_gop_frames,552+ initial_width,553+ initial_height,554+ );555+ let mut applied_desired_revision = 0_u64;556+ let mut pending_force_keyframe_revision = None;557+ let mut pending_resolution = None;558+ status.send_modify(|status| {559+ status.applied_width = Some(initial_width);560+ status.applied_height = Some(initial_height);561+ });562+563+ loop {564+ if let Some(message) = bus.timed_pop(gst::ClockTime::ZERO) {565+ match message.view() {566+ gst::MessageView::Error(error) => {567+ bail!(568+ "in-process GStreamer pipeline error from {:?}: {} ({:?})",569+ error.src().map(|source| source.path_string()),570+ error.error(),571+ error.debug()572+ );573+ }574+ gst::MessageView::Eos(_) => {575+ bail!("in-process GStreamer pipeline ended unexpectedly");576+ }577+ _ => {}578+ }579+ }580+581+ let desired_state = *desired.borrow_and_update();582+ if desired_state.revision > applied_desired_revision {583+ apply_encoder_control(584+ &encoder,585+ &scale_caps,586+ &sink,587+ desired_state,588+ &status,589+ ¤t_gop_sender,590+ &mut pending_force_keyframe_revision,591+ &mut pending_resolution,592+ )?;593+ applied_desired_revision = desired_state.revision;594+ }595+596+ if pending_resolution.is_some_and(|pending: PendingResolutionTransition| {597+ Instant::now() >= pending.deadline598+ }) {599+ let pending = pending_resolution.expect("checked as present");600+ bail!(601+ "encoder resolution revision {} did not produce a configured {}x{} IDR within {:?}",602+ pending.target.revision,603+ pending.target.width,604+ pending.target.height,605+ RESOLUTION_APPLY_TIMEOUT606+ );607+ }608+609+ if let Some(sample) = sink.try_pull_sample(gst::ClockTime::from_mseconds(50)) {610+ let (width, height) = encoded_sample_dimensions(&sample)?;611+ let buffer = sample612+ .buffer()613+ .context("in-process appsink sample has no buffer")?;614+ let map = buffer615+ .map_readable()616+ .context("mapping in-process encoded access unit")?;617+ ensure!(618+ map.as_slice().len() <= MAX_ENCODE_BUFFER,619+ "in-process encoded access unit exceeds {MAX_ENCODE_BUFFER} bytes"620+ );621+ let access_unit = map.as_slice();622+ let configured_idr =623+ is_h264_keyframe(access_unit) && has_h264_codec_config(access_unit);624+ let Some(discontinuity) = classify_resolution_sample(625+ (publisher.width, publisher.height),626+ pending_resolution,627+ (width, height),628+ configured_idr,629+ )?630+ else {631+ continue;632+ };633+ let configured_idr = publisher.publish_with_metadata(634+ access_unit.to_vec(),635+ width,636+ height,637+ discontinuity,638+ )?;639+ if discontinuity {640+ let applied = pending_resolution641+ .take()642+ .expect("discontinuity requires a pending resolution");643+ status.send_modify(|status| {644+ status.applied_resolution_revision = Some(applied.target.revision);645+ status.applied_width = Some(width);646+ status.applied_height = Some(height);647+ });648+ }649+ acknowledge_force_keyframe_if_configured_idr(650+ configured_idr,651+ &mut pending_force_keyframe_revision,652+ &status,653+ );654+ } else if sink.is_eos() {655+ bail!("in-process GStreamer appsink reached EOS unexpectedly");656+ }657+658+ if publisher.receivers_closed() {659+ debug!("in-process encoded source has no receivers");660+ return Ok(());661+ }662+ }663+ })();664+ if let Err(error) = pipeline.set_state(gst::State::Null) {665+ warn!(%error, "failed to stop in-process GStreamer pipeline cleanly");666+ }667+ result668+}669+670+671+fn validate_active_encoder_device_path(672+ encoder: &gstreamer::Element,673+ expected_device_path: &str,674+) -> Result<()> {675+ use gstreamer as gst;676+ use gstreamer::prelude::*;677+678+ let property = encoder679+ .find_property("device-path")680+ .context("configured in-process VA encoder has no device-path property")?;681+ ensure!(682+ property.flags().contains(gst::glib::ParamFlags::READABLE),683+ "configured in-process VA encoder device-path property is not readable"684+ );685+ let observed_device_path = encoder.property::<Option<String>>("device-path");686+ ensure!(687+ observed_device_path.as_deref() == Some(expected_device_path),688+ "configured in-process VA encoder uses device-path {observed_device_path:?}, expected {expected_device_path:?}"689+ );690+ Ok(())691+}692+693+694+fn validate_mutable_playing_bitrate(encoder: &gstreamer::Element) -> Result<()> {695+ use gstreamer as gst;696+ use gstreamer::prelude::*;697+698+ let property = encoder699+ .find_property("bitrate")700+ .context("configured in-process encoder has no bitrate property")?;701+ ensure!(702+ property.flags().contains(gst::glib::ParamFlags::WRITABLE),703+ "configured in-process encoder bitrate property is not writable"704+ );705+ ensure!(706+ property.flags().contains(gst::PARAM_FLAG_MUTABLE_PLAYING),707+ "configured in-process encoder bitrate property is not mutable while playing"708+ );709+ Ok(())710+}711+712+713+fn validate_resolution_capsfilter(capsfilter: &gstreamer::Element) -> Result<()> {714+ use gstreamer::prelude::*;715+716+ let property = capsfilter717+ .find_property("caps")718+ .context("configured scale capsfilter has no caps property")?;719+ ensure!(720+ property721+ .flags()722+ .contains(gstreamer::glib::ParamFlags::WRITABLE),723+ "configured scale capsfilter caps property is not writable"724+ );725+ let caps = capsfilter.property::<gstreamer::Caps>("caps");726+ let _ = dimensions_from_caps(&caps)?;727+ Ok(())728+}729+730+731+fn dimensions_from_caps(caps: &gstreamer::CapsRef) -> Result<(u16, u16)> {732+ let structure = caps733+ .structure(0)734+ .context("configured video caps have no structure")?;735+ let width = structure736+ .get::<i32>("width")737+ .context("configured video caps have no integer width")?;738+ let height = structure739+ .get::<i32>("height")740+ .context("configured video caps have no integer height")?;741+ Ok((742+ u16::try_from(width).context("configured video caps width is outside u16")?,743+ u16::try_from(height).context("configured video caps height is outside u16")?,744+ ))745+}746+747+748+fn encoded_sample_dimensions(sample: &gstreamer::Sample) -> Result<(u16, u16)> {749+ let caps = sample750+ .caps()751+ .context("in-process encoded sample has no negotiated caps")?;752+ dimensions_from_caps(caps)753+}754+755+756+fn apply_resolution_caps(757+ capsfilter: &gstreamer::Element,758+ resolution: RevisedResolution,759+) -> Result<()> {760+ use gstreamer::prelude::*;761+762+ let mut caps = capsfilter.property::<gstreamer::Caps>("caps");763+ let caps_mut = caps.make_mut();764+ let structure = caps_mut765+ .structure_mut(0)766+ .context("configured scale capsfilter has no caps structure")?;767+ structure.set("width", i32::from(resolution.width));768+ structure.set("height", i32::from(resolution.height));769+ capsfilter.set_property("caps", caps);770+ let readback = capsfilter.property::<gstreamer::Caps>("caps");771+ ensure!(772+ dimensions_from_caps(&readback)? == (resolution.width, resolution.height),773+ "scale capsfilter resolution readback does not match requested {}x{}",774+ resolution.width,775+ resolution.height776+ );777+ Ok(())778+}779+780+781+fn push_configured_keyframe_request(sink: &gstreamer_app::AppSink) -> Result<()> {782+ use gstreamer::prelude::*;783+784+ let event = gstreamer_video::UpstreamForceKeyUnitEvent::builder()785+ .all_headers(true)786+ .build();787+ let sink_pad = sink788+ .static_pad("sink")789+ .context("configured in-process appsink has no sink pad")?;790+ ensure!(791+ sink_pad.push_event(event),792+ "in-process encoder rejected upstream ForceKeyUnit"793+ );794+ Ok(())795+}796+797+798+fn apply_encoder_control(799+ encoder: &gstreamer::Element,800+ scale_caps: &gstreamer::Element,801+ sink: &gstreamer_app::AppSink,802+ desired: EncoderControlDesired,803+ status: &watch::Sender<EncoderControlStatus>,804+ current_gop_sender: &watch::Sender<Option<EncodedGop>>,805+ pending_force_keyframe_revision: &mut Option<u64>,806+ pending_resolution: &mut Option<PendingResolutionTransition>,807+) -> Result<()> {808+ let current = *status.borrow();809+ if let Some(bitrate) = desired.bitrate810+ && current811+ .applied_bitrate_revision812+ .is_none_or(|revision| bitrate.revision > revision)813+ {814+ encoder.set_property("bitrate", bitrate.kbps);815+ let readback = encoder.property::<u32>("bitrate");816+ ensure!(817+ readback == bitrate.kbps,818+ "encoder bitrate readback mismatch: requested {} kbps, observed {readback} kbps",819+ bitrate.kbps820+ );821+ status.send_modify(|status| {822+ status.applied_bitrate_revision = Some(bitrate.revision);823+ status.applied_bitrate_kbps = Some(readback);824+ });825+ }826+827+ let mut keyframe_requested = false;828+ if let Some(resolution) = desired.resolution829+ && current830+ .requested_resolution_revision831+ .is_none_or(|requested| resolution.revision > requested)832+ {833+ apply_resolution_caps(scale_caps, resolution)?;834+ current_gop_sender.send_replace(None);835+ push_configured_keyframe_request(sink)?;836+ keyframe_requested = true;837+ *pending_resolution = Some(PendingResolutionTransition {838+ target: resolution,839+ deadline: Instant::now() + RESOLUTION_APPLY_TIMEOUT,840+ });841+ status.send_modify(|status| {842+ status.requested_resolution_revision = Some(resolution.revision);843+ });844+ }845+846+ if let Some(revision) = desired.force_keyframe_revision847+ && current848+ .requested_force_keyframe_revision849+ .is_none_or(|requested| revision > requested)850+ {851+ if !keyframe_requested {852+ push_configured_keyframe_request(sink)?;853+ }854+ *pending_force_keyframe_revision = Some(revision);855+ status.send_modify(|status| {856+ status.requested_force_keyframe_revision = Some(revision);857+ });858+ }859+860+ status.send_modify(|status| status.latest_revision = desired.revision);861+ Ok(())862+}863+864+865+fn acknowledge_force_keyframe_if_configured_idr(866+ configured_idr: bool,867+ pending_revision: &mut Option<u64>,868+ status: &watch::Sender<EncoderControlStatus>,869+) {870+ if configured_idr && let Some(revision) = pending_revision.take() {871+ status.send_modify(|status| {872+ status.acknowledged_force_keyframe_revision = Some(revision);873+ });874+ }875+}876+877+878+mod tests {879+ use super::*;880+881+ 882+ fn encoder_control_retains_initial_session_dimensions() {883+ let (native, _desired, _status) = EncoderControl::new(2_560, 1_600);884+ let (other, _desired, _status) = EncoderControl::new(1_366, 768);885+886+ assert_eq!(native.initial_dimensions(), (2_560, 1_600));887+ assert_eq!(other.initial_dimensions(), (1_366, 768));888+ native.request_resolution(1_280, 800).unwrap();889+ assert_eq!(native.initial_dimensions(), (2_560, 1_600));890+ assert_eq!(other.initial_dimensions(), (1_366, 768));891+ }892+893+ 894+ async fn encoder_control_coalesces_latest_state_and_acknowledges_only_configured_idr() {895+ let (control, mut desired, status) = EncoderControl::new(1_280, 800);896+897+ let superseded_bitrate_revision = control.request_bitrate_kbps(4_000).unwrap();898+ let bitrate_revision = control.request_bitrate_kbps(8_000).unwrap();899+ let superseded_resolution_revision = control.request_resolution(960, 600).unwrap();900+ let resolution_revision = control.request_resolution(640, 400).unwrap();901+ let force_keyframe_revision = control.request_force_keyframe().unwrap();902+903+ assert!(superseded_bitrate_revision < bitrate_revision);904+ assert!(bitrate_revision < superseded_resolution_revision);905+ assert!(superseded_resolution_revision < resolution_revision);906+ assert!(resolution_revision < force_keyframe_revision);907+ let latest = *desired.borrow_and_update();908+ assert_eq!(latest.revision, force_keyframe_revision);909+ assert_eq!(910+ latest.bitrate,911+ Some(RevisedBitrate {912+ revision: bitrate_revision,913+ kbps: 8_000,914+ })915+ );916+ assert_eq!(917+ latest.resolution,918+ Some(RevisedResolution {919+ revision: resolution_revision,920+ width: 640,921+ height: 400,922+ })923+ );924+ assert_eq!(925+ latest.force_keyframe_revision,926+ Some(force_keyframe_revision)927+ );928+929+ status.send_modify(|status| {930+ status.latest_revision = latest.revision;931+ status.applied_bitrate_revision = Some(bitrate_revision);932+ status.applied_bitrate_kbps = Some(8_000);933+ status.requested_resolution_revision = Some(resolution_revision);934+ status.applied_resolution_revision = Some(resolution_revision);935+ status.applied_width = Some(640);936+ status.applied_height = Some(400);937+ status.requested_force_keyframe_revision = Some(force_keyframe_revision);938+ });939+ let mut pending = Some(force_keyframe_revision);940+ acknowledge_force_keyframe_if_configured_idr(false, &mut pending, &status);941+ assert_eq!(pending, Some(force_keyframe_revision));942+ assert_eq!(control.status().acknowledged_force_keyframe_revision, None);943+944+ acknowledge_force_keyframe_if_configured_idr(true, &mut pending, &status);945+ assert_eq!(pending, None);946+ let applied = control947+ .wait_for_bitrate_applied(bitrate_revision, Duration::from_millis(10))948+ .await949+ .unwrap();950+ assert_eq!(applied.applied_bitrate_kbps, Some(8_000));951+ let applied_resolution = control952+ .wait_for_resolution_applied(resolution_revision, 640, 400, Duration::from_millis(10))953+ .await954+ .unwrap();955+ assert_eq!(applied_resolution.applied_width, Some(640));956+ assert_eq!(applied_resolution.applied_height, Some(400));957+ assert!(958+ control959+ .wait_for_resolution_applied(960+ superseded_resolution_revision,961+ 960,962+ 600,963+ Duration::from_millis(10),964+ )965+ .await966+ .unwrap_err()967+ .to_string()968+ .contains("superseded")969+ );970+ let acknowledged = control971+ .wait_for_force_keyframe_acknowledged(972+ force_keyframe_revision,973+ Duration::from_millis(10),974+ )975+ .await976+ .unwrap();977+ assert_eq!(978+ acknowledged.acknowledged_force_keyframe_revision,979+ Some(force_keyframe_revision)980+ );981+ assert!(control.request_bitrate_kbps(0).is_err());982+ assert!(control.request_resolution(0, 400).is_err());983+ assert!(control.request_resolution(641, 400).is_err());984+ assert!(985+ control986+ .request_bitrate_kbps(MAX_ENCODER_BITRATE_KBPS + 1)987+ .is_err()988+ );989+990+ let (closed_control, _desired, closed_status) = EncoderControl::new(1_280, 800);991+ let closed_revision = closed_control.request_force_keyframe().unwrap();992+ drop(closed_status);993+ assert!(994+ closed_control995+ .wait_for_force_keyframe_acknowledged(closed_revision, Duration::from_millis(10))996+ .await997+ .unwrap_err()998+ .to_string()999+ .contains("status closed")1000+ );1001+ }1002+1003+ 1004+ async fn recovery_waiter_accepts_a_newer_configured_idr_revision() {1005+ let (control, _desired, status) = EncoderControl::new(1_280, 800);1006+ let recovery_revision = control.request_force_keyframe().unwrap();1007+ let newer_revision = control.request_force_keyframe().unwrap();1008+ assert!(newer_revision > recovery_revision);1009+ status.send_modify(|status| {1010+ status.requested_force_keyframe_revision = Some(newer_revision);1011+ status.acknowledged_force_keyframe_revision = Some(newer_revision);1012+ });1013+1014+ let acknowledged = control1015+ .wait_for_recovery_keyframe_acknowledged(recovery_revision, Duration::from_millis(10))1016+ .await1017+ .unwrap();1018+ assert_eq!(1019+ acknowledged.acknowledged_force_keyframe_revision,1020+ Some(newer_revision)1021+ );1022+ assert!(1023+ control1024+ .wait_for_force_keyframe_acknowledged(recovery_revision, Duration::from_millis(10),)1025+ .await1026+ .unwrap_err()1027+ .to_string()1028+ .contains("superseded")1029+ );1030+ }1031+1032+ 1033+ fn resolution_transition_suppresses_until_exact_configured_target_idr() {1034+ let transition = PendingResolutionTransition {1035+ target: RevisedResolution {1036+ revision: 7,1037+ width: 960,1038+ height: 600,1039+ },1040+ deadline: Instant::now() + Duration::from_secs(1),1041+ };1042+ assert!(transition.deadline > Instant::now());1043+ assert_eq!(1044+ classify_resolution_sample((1280, 800), Some(transition), (1280, 800), true).unwrap(),1045+ None1046+ );1047+ assert_eq!(1048+ classify_resolution_sample((1280, 800), Some(transition), (960, 600), false).unwrap(),1049+ None1050+ );1051+ assert_eq!(1052+ classify_resolution_sample((1280, 800), Some(transition), (960, 600), true).unwrap(),1053+ Some(true)1054+ );1055+ assert_eq!(1056+ classify_resolution_sample((960, 600), None, (960, 600), false).unwrap(),1057+ Some(false)1058+ );1059+ assert!(classify_resolution_sample((960, 600), None, (1280, 800), true).is_err());1060+ }1061+1062+ 1063+ 1064+ fn in_process_pipeline_drops_only_before_encoding() {1065+ use super::super::tests::{configured_video_mode, gamescope_config};1066+ use crate::config::GamescopeEncoderBackend;1067+1068+ let mut config = gamescope_config();1069+ config.gamescope_pipewire.as_mut().unwrap().encoder_backend =1070+ GamescopeEncoderBackend::InProcessGstreamer;1071+ let description =1072+ gamescope_in_process_pipeline_description(&config, "1234", configured_video_mode())1073+ .unwrap();1074+1075+ assert!(description.contains(1076+ "queue max-size-buffers=1 max-size-bytes=0 max-size-time=0 leaky=downstream"1077+ ));1078+ assert!(description.contains(1079+ "capsfilter name=sigil_scale_caps caps=video/x-raw,format=NV12,width=1280,height=800,framerate=60/1 caps-change-mode=delayed"1080+ ));1081+ assert!(1082+ description.contains("appsink name=sigil_sink max-buffers=1 drop=false sync=false")1083+ );1084+ assert!(!description.contains("appsink name=sigil_sink max-buffers=1 drop=true"));1085+ }1086+1087+ 1088+ 1089+ 1090+ async fn in_process_gstreamer_x264_smoke() {1091+ let description = "videotestsrc is-live=true pattern=ball \1092+ ! queue max-size-buffers=1 max-size-bytes=0 max-size-time=0 leaky=downstream \1093+ ! videoconvert ! videoscale \1094+ ! capsfilter name=sigil_scale_caps caps=video/x-raw,width=320,height=180,framerate=30/1 caps-change-mode=delayed \1095+ ! x264enc name=sigil_encoder bitrate=2000 tune=zerolatency speed-preset=ultrafast key-int-max=15 bframes=0 byte-stream=true aud=true \1096+ ! h264parse config-interval=-1 \1097+ ! video/x-h264,stream-format=byte-stream,alignment=au \1098+ ! appsink name=sigil_sink max-buffers=1 drop=false sync=false"1099+ .to_owned();1100+ let mut source =1101+ spawn_in_process_pipeline(description, SessionClock::start(), 15, None, None, 320, 180)1102+ .unwrap();1103+ let control = source1104+ .encoder_control1105+ .clone()1106+ .expect("x264 source has no encoder control");1107+1108+ tokio::time::timeout(Duration::from_secs(10), async {1109+ loop {1110+ source.frames.changed().await.unwrap();1111+ if source1112+ .frames1113+ .borrow()1114+ .as_ref()1115+ .is_some_and(|frame| frame.keyframe && frame.codec_config)1116+ {1117+ break;1118+ }1119+ }1120+ })1121+ .await1122+ .expect("x264 did not publish an initial configured IDR");1123+1124+ let bitrate_revision = control.request_bitrate_kbps(1_500).unwrap();1125+ let force_keyframe_revision = control.request_force_keyframe().unwrap();1126+ let bitrate_status = control1127+ .wait_for_bitrate_applied(bitrate_revision, Duration::from_secs(10))1128+ .await1129+ .expect("x264 did not apply the bitrate revision");1130+ assert_eq!(bitrate_status.applied_bitrate_kbps, Some(1_500));1131+ control1132+ .wait_for_force_keyframe_acknowledged(force_keyframe_revision, Duration::from_secs(10))1133+ .await1134+ .expect("x264 did not acknowledge a configured forced IDR");1135+ let before_resolution_sequence = source1136+ .current_gop1137+ .borrow()1138+ .as_ref()1139+ .and_then(|gop| gop.frames.first())1140+ .map(|frame| frame.sequence)1141+ .expect("x264 has no configured GOP before resolution transition");1142+1143+ let reduced_revision = control.request_resolution(256, 144).unwrap();1144+ control1145+ .wait_for_resolution_applied(reduced_revision, 256, 144, Duration::from_secs(10))1146+ .await1147+ .expect("x264 did not apply the reduced resolution");1148+ let reduced = source1149+ .current_gop1150+ .borrow()1151+ .as_ref()1152+ .and_then(|gop| gop.frames.first())1153+ .cloned()1154+ .expect("x264 has no reduced configured GOP");1155+ assert_eq!((reduced.width, reduced.height), (256, 144));1156+ assert!(reduced.keyframe && reduced.codec_config && reduced.discontinuity);1157+ assert!(reduced.sequence > before_resolution_sequence);1158+1159+ let restored_revision = control.request_resolution(320, 180).unwrap();1160+ control1161+ .wait_for_resolution_applied(restored_revision, 320, 180, Duration::from_secs(10))1162+ .await1163+ .expect("x264 did not restore the native resolution");1164+ let restored = source1165+ .current_gop1166+ .borrow()1167+ .as_ref()1168+ .and_then(|gop| gop.frames.first())1169+ .cloned()1170+ .expect("x264 has no restored configured GOP");1171+ assert_eq!((restored.width, restored.height), (320, 180));1172+ assert!(restored.keyframe && restored.codec_config && restored.discontinuity);1173+ assert!(restored.sequence > reduced.sequence);1174+1175+ {1176+ let current_gop = source.current_gop.borrow();1177+ let gop = current_gop1178+ .as_ref()1179+ .expect("x264 did not retain a current GOP");1180+ assert!(gop.frames[0].keyframe && gop.frames[0].codec_config);1181+ assert!(gop.frames.len() <= 15);1182+ }1183+ let EncodedSource {1184+ frames,1185+ current_gop,1186+ task,1187+ ..1188+ } = source;1189+ drop(frames);1190+ drop(current_gop);1191+ tokio::time::timeout(Duration::from_secs(2), task)1192+ .await1193+ .expect("x264 pipeline did not stop after its receivers closed")1194+ .expect("x264 pipeline task panicked")1195+ .expect("x264 pipeline returned an error");1196+ }1197+}@@ -0,0 +1,1156 @@
1+use std::borrow::Cow;2+use std::process::{ExitStatus, Stdio};3+use std::sync::Arc;4+use std::time::{Duration, Instant};5+6+use anyhow::{Context, Result, bail, ensure};7+use serde_json::Value;8+use sigil_protocol::PointerSurfaceDimensions;9+use tokio::io::{AsyncRead, AsyncReadExt};10+use tokio::process::Command;11+use tokio::sync::watch;12+use tracing::warn;13+14+use crate::clock::SessionClock;15+16+use crate::config::VaapiRateControl;17+use crate::config::{GamescopeEncoderBackend, GamescopePipewireConfig, HostConfig};18+19+mod annexb;20+mod external;21+mod in_process;22+mod preflight;23+24+25+use external::gamescope_pipeline_args;26+use external::{forward_annex_b_stream, spawn_gamescope_pipewire_with_target};27+28+pub(crate) use in_process::EncoderControlTestHarness;29+30+use in_process::spawn_gamescope_pipewire_in_process_with_target;31+pub use in_process::{EncoderControl, EncoderControlStatus};32+use preflight::preflight_gamescope_static;33+pub(crate) use preflight::{preflight_gstreamer_element, validate_executable};34+35+const MAX_ENCODE_BUFFER: usize = 16 * 1024 * 1024;36+const MAX_CURRENT_GOP_BYTES: usize = 32 * 1024 * 1024;37+const MAX_PW_DUMP_OUTPUT: usize = 8 * 1024 * 1024;38+const MAX_DIAGNOSTIC_OUTPUT: usize = 512 * 1024;39+const PREFLIGHT_TIMEOUT: Duration = Duration::from_secs(5);40+41+42+pub struct EncodedFrame {43+ pub sequence: u64,44+ pub width: u16,45+ pub height: u16,46+ /// Session-monotonic timestamp when the complete encoded access unit became47+ /// observable to the daemon, using the same epoch as audio. Raw PipeWire48+ /// capture PTS is not preserved by the current stdout bridge, so this must49+ /// not be presented as capture age.50+ pub capture_timestamp_micros: u64,51+ /// Post-encode observation PTS in the shared session clock. This preserves52+ /// real gaps in damage-driven video output.53+ pub presentation_timestamp_micros: i64,54+ pub observed_at: Instant,55+ pub keyframe: bool,56+ pub codec_config: bool,57+ /// This configured keyframe begins a decoder generation that must not use58+ /// reference state from the preceding encoded resolution.59+ pub discontinuity: bool,60+ pub data: Arc<[u8]>,61+}62+63+64+pub(crate) struct EncodedGop {65+ pub frames: Vec<EncodedFrame>,66+ pub(crate) payload_bytes: usize,67+}68+69+pub struct EncodedSource {70+ pub frames: watch::Receiver<Option<EncodedFrame>>,71+ /// A bounded snapshot of every access unit from the latest IDR through the72+ /// current frame. A new subscriber can therefore reconstruct the current73+ /// static image without receiving an orphan inter-frame.74+ pub(crate) current_gop: watch::Receiver<Option<EncodedGop>>,75+ pub task: tokio::task::JoinHandle<Result<()>>,76+ pub pointer_surface_dimensions: Option<PointerSurfaceDimensions>,77+ pub encoder_control: Option<EncoderControl>,78+}79+80+pub fn spawn_test_pattern(config: HostConfig, session_clock: SessionClock) -> EncodedSource {81+ let (sender, receiver) = watch::channel(None);82+ let (current_gop_sender, current_gop) = watch::channel(None);83+ let task = tokio::spawn(async move {84+ run_test_pattern(config, session_clock, sender, current_gop_sender).await85+ });86+ EncodedSource {87+ frames: receiver,88+ current_gop,89+ task,90+ pointer_surface_dimensions: None,91+ encoder_control: None,92+ }93+}94+95+96+pub struct GamescopePreflight {97+ /// PipeWire object.serial, resolved from exact configured properties.98+ pub target_object: String,99+ /// Native Gamescope surface used by the relative-pointer coordinate space.100+ pub pointer_surface_dimensions: PointerSurfaceDimensions,101+ /// Bounded encoded mode after applying an optional explicit size override.102+ pub video_mode: CaptureVideoMode,103+}104+105+106+pub struct CaptureVideoMode {107+ pub width: u16,108+ pub height: u16,109+ pub framerate: u32,110+}111+112+impl CaptureVideoMode {113+ fn resolve(config: &HostConfig, native: PointerSurfaceDimensions) -> Result<Self> {114+ let configured = config.configured_dimensions();115+ let (width, height) =116+ configured.unwrap_or((u32::from(native.width), u32::from(native.height)));117+ ensure!(118+ (64..=7_680).contains(&width) && (64..=4_320).contains(&height),119+ "resolved Gamescope encoded dimensions {width}x{height} are outside H.264 bounds"120+ );121+ ensure!(122+ width.is_multiple_of(2) && height.is_multiple_of(2),123+ "resolved Gamescope encoded dimensions {width}x{height} must be even"124+ );125+ if configured.is_some() {126+ ensure!(127+ u64::from(width) * u64::from(native.height)128+ == u64::from(height) * u64::from(native.width),129+ "configured Gamescope encoded dimensions {width}x{height} must preserve native {}x{} aspect ratio",130+ native.width,131+ native.height132+ );133+ }134+ Ok(Self {135+ width: u16::try_from(width).context("resolved Gamescope width exceeds metadata")?,136+ height: u16::try_from(height).context("resolved Gamescope height exceeds metadata")?,137+ framerate: config.framerate,138+ })139+ }140+}141+142+pub async fn preflight_gamescope_pipewire(config: &HostConfig) -> Result<GamescopePreflight> {143+ config.validate()?;144+ preflight_gamescope_static(config).await?;145+ resolve_gamescope_pipewire_target(config).await146+}147+148+async fn resolve_gamescope_pipewire_target(config: &HostConfig) -> Result<GamescopePreflight> {149+ config.validate()?;150+ let pipewire = gamescope_config(config)?;151+ let mut command = Command::new(&pipewire.pw_dump_path);152+ command.env("LC_ALL", "C");153+ let output = run_bounded_command(command, MAX_PW_DUMP_OUTPUT).await?;154+ ensure!(155+ output.status.success(),156+ "pw-dump failed: {}",157+ diagnostic(&output.stderr)158+ );159+ resolve_pipewire_node(&output.stdout, config)160+}161+162+pub async fn spawn_gamescope_pipewire(163+ config: HostConfig,164+ session_clock: SessionClock,165+) -> Result<EncodedSource> {166+ let preflight = preflight_gamescope_pipewire(&config).await?;167+ spawn_gamescope_pipewire_with_preflight(config, session_clock, preflight)168+}169+170+/// Start capture after `serve` has completed the static executable/GstVA171+/// preflight. The PipeWire node is still resolved immediately before each172+/// session because its object serial can change when Gamescope restarts.173+pub async fn spawn_gamescope_pipewire_after_static_preflight(174+ config: HostConfig,175+ session_clock: SessionClock,176+) -> Result<EncodedSource> {177+ let preflight = resolve_gamescope_pipewire_target(&config).await?;178+ spawn_gamescope_pipewire_with_preflight(config, session_clock, preflight)179+}180+181+fn spawn_gamescope_pipewire_with_preflight(182+ config: HostConfig,183+ session_clock: SessionClock,184+ preflight: GamescopePreflight,185+) -> Result<EncodedSource> {186+ match gamescope_config(&config)?.encoder_backend {187+ GamescopeEncoderBackend::ExternalGstLaunch => {188+ spawn_gamescope_pipewire_with_target(config, session_clock, preflight)189+ }190+ GamescopeEncoderBackend::InProcessGstreamer => {191+ 192+ {193+ spawn_gamescope_pipewire_in_process_with_target(config, session_clock, preflight)194+ }195+ 196+ {197+ let _ = (session_clock, preflight);198+ bail!(199+ "gamescope_pipewire.encoder_backend=in-process-gstreamer requires a Linux Sigil build with the in-process-gstreamer feature"200+ )201+ }202+ }203+ }204+}205+206+async fn run_test_pattern(207+ config: HostConfig,208+ session_clock: SessionClock,209+ sender: watch::Sender<Option<EncodedFrame>>,210+ current_gop_sender: watch::Sender<Option<EncodedGop>>,211+) -> Result<()> {212+ let (width, height) = config.test_pattern_dimensions()?;213+ let input = format!(214+ "testsrc2=size={}x{}:rate={}",215+ width, height, config.framerate216+ );217+ let gop = interactive_gop_frames(config.framerate).to_string();218+219+ let mut command = Command::new(&config.ffmpeg_path);220+ command221+ .arg("-hide_banner")222+ .arg("-loglevel")223+ .arg("warning")224+ .arg("-re")225+ .arg("-f")226+ .arg("lavfi")227+ .arg("-i")228+ .arg(input)229+ .arg("-an")230+ .arg("-c:v")231+ .arg("libx264")232+ .arg("-preset")233+ .arg("ultrafast")234+ .arg("-tune")235+ .arg("zerolatency")236+ .arg("-g")237+ .arg(&gop)238+ .arg("-keyint_min")239+ .arg(&gop)240+ .arg("-sc_threshold")241+ .arg("0")242+ .arg("-bf")243+ .arg("0")244+ .arg("-pix_fmt")245+ .arg("yuv420p")246+ .arg("-bsf:v")247+ .arg("h264_metadata=aud=insert")248+ .arg("-f")249+ .arg("h264")250+ .arg("pipe:1")251+ .stdin(Stdio::null())252+ .stdout(Stdio::piped())253+ .stderr(Stdio::piped())254+ .kill_on_drop(true);255+256+ let mut child = command257+ .spawn()258+ .with_context(|| format!("starting {}", config.ffmpeg_path.display()))?;259+ let mut stdout = child.stdout.take().context("ffmpeg stdout was not piped")?;260+ let stderr = child.stderr.take().context("ffmpeg stderr was not piped")?;261+ let stderr_task = tokio::spawn(log_stderr_chunks(stderr, "ffmpeg"));262+263+ let result = forward_annex_b_stream(264+ session_clock,265+ &mut stdout,266+ &sender,267+ ¤t_gop_sender,268+ interactive_gop_frames(config.framerate) as usize,269+ u16::try_from(width).context("configured width exceeds encoder metadata")?,270+ u16::try_from(height).context("configured height exceeds encoder metadata")?,271+ )272+ .await;273+ if result.is_ok() {274+ let _ = child.kill().await;275+ }276+ let status = child.wait().await.context("waiting for ffmpeg")?;277+ stderr_task.abort();278+ match result {279+ Ok(()) => Ok(()),280+ Err(error) => Err(error).with_context(|| format!("ffmpeg exited with {status}")),281+ }282+}283+284+struct AccessUnitPublisher<'a> {285+ session_clock: SessionClock,286+ sender: &'a watch::Sender<Option<EncodedFrame>>,287+ current_gop_sender: &'a watch::Sender<Option<EncodedGop>>,288+ max_gop_frames: usize,289+ width: u16,290+ height: u16,291+ sequence: u64,292+}293+294+impl<'a> AccessUnitPublisher<'a> {295+ fn new(296+ session_clock: SessionClock,297+ sender: &'a watch::Sender<Option<EncodedFrame>>,298+ current_gop_sender: &'a watch::Sender<Option<EncodedGop>>,299+ max_gop_frames: usize,300+ width: u16,301+ height: u16,302+ ) -> Self {303+ Self {304+ session_clock,305+ sender,306+ current_gop_sender,307+ max_gop_frames,308+ width,309+ height,310+ sequence: 0,311+ }312+ }313+314+ fn publish(&mut self, access_unit: Vec<u8>) -> Result<bool> {315+ self.publish_with_metadata(access_unit, self.width, self.height, false)316+ }317+318+ fn publish_with_metadata(319+ &mut self,320+ access_unit: Vec<u8>,321+ width: u16,322+ height: u16,323+ discontinuity: bool,324+ ) -> Result<bool> {325+ ensure!(!access_unit.is_empty(), "encoded access unit is empty");326+ ensure!(327+ access_unit.len() <= MAX_ENCODE_BUFFER,328+ "encoded access unit exceeds {MAX_ENCODE_BUFFER} bytes"329+ );330+ let observed_at = Instant::now();331+ let capture_timestamp_micros = self.session_clock.micros_at(observed_at);332+ let presentation_timestamp_micros =333+ i64::try_from(capture_timestamp_micros).unwrap_or(i64::MAX);334+ let keyframe = is_h264_keyframe(&access_unit);335+ let codec_config = has_h264_codec_config(&access_unit);336+ let frame = EncodedFrame {337+ sequence: self.sequence,338+ width,339+ height,340+ capture_timestamp_micros,341+ presentation_timestamp_micros,342+ observed_at,343+ keyframe,344+ codec_config,345+ discontinuity,346+ data: Arc::from(access_unit),347+ };348+ self.sequence = self349+ .sequence350+ .checked_add(1)351+ .context("encoded frame sequence exhausted")?;352+ publish_encoded_frame(353+ self.sender,354+ self.current_gop_sender,355+ frame,356+ self.max_gop_frames,357+ MAX_CURRENT_GOP_BYTES,358+ );359+ self.width = width;360+ self.height = height;361+ Ok(keyframe && codec_config)362+ }363+364+ fn receivers_closed(&self) -> bool {365+ self.sender.is_closed() && self.current_gop_sender.is_closed()366+ }367+}368+369+fn publish_encoded_frame(370+ sender: &watch::Sender<Option<EncodedFrame>>,371+ current_gop_sender: &watch::Sender<Option<EncodedGop>>,372+ frame: EncodedFrame,373+ max_gop_frames: usize,374+ max_gop_bytes: usize,375+) {376+ if !current_gop_sender.is_closed() {377+ current_gop_sender.send_if_modified(|current_gop| {378+ update_current_gop(current_gop, frame.clone(), max_gop_frames, max_gop_bytes)379+ });380+ }381+ sender.send_replace(Some(frame.clone()));382+}383+384+fn update_current_gop(385+ current_gop: &mut Option<EncodedGop>,386+ frame: EncodedFrame,387+ max_gop_frames: usize,388+ max_gop_bytes: usize,389+) -> bool {390+ let independently_decodable = frame.keyframe && frame.codec_config;391+ if independently_decodable {392+ let payload_bytes = frame.data.len();393+ if max_gop_frames == 0 || payload_bytes > max_gop_bytes {394+ return current_gop.take().is_some();395+ }396+ *current_gop = Some(EncodedGop {397+ frames: vec![frame],398+ payload_bytes,399+ });400+ return true;401+ }402+403+ let Some(gop) = current_gop.as_mut() else {404+ return false;405+ };406+ let contiguous = gop407+ .frames408+ .last()409+ .and_then(|previous| previous.sequence.checked_add(1))410+ == Some(frame.sequence);411+ let payload_bytes = gop.payload_bytes.saturating_add(frame.data.len());412+ if !contiguous || gop.frames.len() >= max_gop_frames || payload_bytes > max_gop_bytes {413+ *current_gop = None;414+ return true;415+ }416+417+ gop.frames.push(frame);418+ gop.payload_bytes = payload_bytes;419+ true420+}421+422+pub(crate) async fn log_stderr_chunks<R>(mut stderr: R, target: &'static str)423+where424+ R: AsyncRead + Unpin,425+{426+ const LOG_INTERVAL: Duration = Duration::from_secs(1);427+ const MAX_LOG_BYTES_PER_INTERVAL: usize = 16 * 1024;428+429+ let mut chunk = [0_u8; 4096];430+ let mut interval_started = tokio::time::Instant::now();431+ let mut logged_bytes = 0_usize;432+ let mut suppressed_bytes = 0_u64;433+ loop {434+ match stderr.read(&mut chunk).await {435+ Ok(0) => return,436+ Ok(count) => {437+ if interval_started.elapsed() >= LOG_INTERVAL {438+ if suppressed_bytes > 0 {439+ warn!(440+ target: "capture-process",441+ process = target,442+ suppressed_bytes,443+ "capture diagnostics rate limited"444+ );445+ }446+ interval_started = tokio::time::Instant::now();447+ logged_bytes = 0;448+ suppressed_bytes = 0;449+ }450+ if logged_bytes.saturating_add(count) <= MAX_LOG_BYTES_PER_INTERVAL {451+ warn!(target: "capture-process", process = target, "{}", String::from_utf8_lossy(&chunk[..count]).trim_end());452+ logged_bytes = logged_bytes.saturating_add(count);453+ } else {454+ suppressed_bytes = suppressed_bytes.saturating_add(count as u64);455+ }456+ }457+ Err(error) => {458+ warn!(target: "capture-process", process = target, %error, "failed reading stderr");459+ return;460+ }461+ }462+ }463+}464+465+fn gamescope_config(config: &HostConfig) -> Result<&GamescopePipewireConfig> {466+ config467+ .gamescope_pipewire468+ .as_ref()469+ .context("gamescope_pipewire configuration is missing")470+}471+472+fn interactive_gop_frames(framerate: u32) -> u32 {473+ // A dropped encoded reference requires the client to wait for the next474+ // independently decodable IDR. Bound that recovery to roughly 500 ms until475+ // the in-process encoder can service explicit keyframe requests.476+ framerate.div_ceil(2).max(1)477+}478+479+pub(crate) async fn resolve_pipewire_node_by_properties(480+ pw_dump_path: &std::path::Path,481+ node_name: &str,482+ media_class: &str,483+ match_properties: &std::collections::BTreeMap<String, String>,484+) -> Result<String> {485+ let mut command = Command::new(pw_dump_path);486+ command.env("LC_ALL", "C");487+ let output = run_bounded_command(command, MAX_PW_DUMP_OUTPUT).await?;488+ ensure!(489+ output.status.success(),490+ "pw-dump failed: {}",491+ diagnostic(&output.stderr)492+ );493+ resolve_pipewire_node_exact(&output.stdout, node_name, media_class, match_properties)494+}495+496+fn resolve_pipewire_node(output: &[u8], config: &HostConfig) -> Result<GamescopePreflight> {497+ let pipewire = gamescope_config(config)?;498+ let target_object = resolve_pipewire_node_exact(499+ output,500+ &pipewire.node_name,501+ &pipewire.media_class,502+ &pipewire.match_properties,503+ )?;504+ let pointer_surface_dimensions = resolve_pointer_surface_dimensions(505+ output,506+ &target_object,507+ &pipewire.node_name,508+ &pipewire.media_class,509+ &pipewire.match_properties,510+ )?;511+ let video_mode = CaptureVideoMode::resolve(config, pointer_surface_dimensions)?;512+ Ok(GamescopePreflight {513+ target_object,514+ pointer_surface_dimensions,515+ video_mode,516+ })517+}518+519+pub(crate) fn resolve_pipewire_node_exact(520+ output: &[u8],521+ node_name: &str,522+ media_class: &str,523+ match_properties: &std::collections::BTreeMap<String, String>,524+) -> Result<String> {525+ let objects: Value = serde_json::from_slice(output).context("parsing bounded pw-dump JSON")?;526+ let objects = objects527+ .as_array()528+ .context("pw-dump output is not an array")?;529+ let mut matches = Vec::new();530+531+ for object in objects {532+ if object.get("type").and_then(Value::as_str) != Some("PipeWire:Interface:Node") {533+ continue;534+ }535+ let Some(properties) = object536+ .get("info")537+ .and_then(|info| info.get("props"))538+ .and_then(Value::as_object)539+ else {540+ continue;541+ };542+ if !pipewire_node_matches(properties, node_name, media_class, match_properties) {543+ continue;544+ }545+ let serial = property_string(properties.get("object.serial"))546+ .context("matching PipeWire node has no object.serial")?;547+ ensure!(548+ !serial.is_empty() && serial.bytes().all(|byte| byte.is_ascii_digit()),549+ "matching PipeWire node has invalid object.serial"550+ );551+ matches.push(serial.into_owned());552+ }553+554+ ensure!(555+ matches.len() == 1,556+ "expected exactly one PipeWire node matching node.name={:?}, media.class={:?}, and configured properties; found {}",557+ node_name,558+ media_class,559+ matches.len()560+ );561+ Ok(matches.remove(0))562+}563+564+fn resolve_pointer_surface_dimensions(565+ output: &[u8],566+ target_object: &str,567+ node_name: &str,568+ media_class: &str,569+ match_properties: &std::collections::BTreeMap<String, String>,570+) -> Result<PointerSurfaceDimensions> {571+ let objects: Value = serde_json::from_slice(output).context("parsing bounded pw-dump JSON")?;572+ let objects = objects573+ .as_array()574+ .context("pw-dump output is not an array")?;575+ let mut matched_node_count = 0_usize;576+ let mut native_size = None;577+578+ for object in objects {579+ if object.get("type").and_then(Value::as_str) != Some("PipeWire:Interface:Node") {580+ continue;581+ }582+ let Some(info) = object.get("info").and_then(Value::as_object) else {583+ continue;584+ };585+ let Some(properties) = info.get("props").and_then(Value::as_object) else {586+ continue;587+ };588+ if !pipewire_node_matches(properties, node_name, media_class, match_properties)589+ || property_string(properties.get("object.serial")).as_deref() != Some(target_object)590+ {591+ continue;592+ }593+ matched_node_count += 1;594+ let formats = info595+ .get("params")596+ .and_then(|params| params.get("EnumFormat"))597+ .and_then(Value::as_array)598+ .context("matching Gamescope PipeWire node has no EnumFormat array")?;599+ for format in formats {600+ if format.get("mediaType").and_then(Value::as_str) != Some("video")601+ || format.get("mediaSubtype").and_then(Value::as_str) != Some("raw")602+ {603+ continue;604+ }605+ let size = format606+ .get("size")607+ .context("Gamescope raw EnumFormat has no native size")?;608+ let size = size.get("default").unwrap_or(size);609+ let width = size610+ .get("width")611+ .and_then(Value::as_u64)612+ .and_then(|value| u16::try_from(value).ok())613+ .context("Gamescope raw EnumFormat native width is not a u16")?;614+ let height = size615+ .get("height")616+ .and_then(Value::as_u64)617+ .and_then(|value| u16::try_from(value).ok())618+ .context("Gamescope raw EnumFormat native height is not a u16")?;619+ let dimensions = PointerSurfaceDimensions::new(width, height)620+ .context("Gamescope raw EnumFormat native size is outside pointer bounds")?;621+ if native_size.is_some_and(|existing| existing != dimensions) {622+ bail!("matching Gamescope PipeWire node advertises ambiguous native sizes");623+ }624+ native_size = Some(dimensions);625+ }626+ }627+628+ ensure!(629+ matched_node_count == 1,630+ "expected exactly one matched Gamescope PipeWire node while resolving its native size; found {matched_node_count}"631+ );632+ native_size.context("matching Gamescope PipeWire node has no raw native EnumFormat size")633+}634+635+fn pipewire_node_matches(636+ properties: &serde_json::Map<String, Value>,637+ node_name: &str,638+ media_class: &str,639+ match_properties: &std::collections::BTreeMap<String, String>,640+) -> bool {641+ property_string(properties.get("node.name")).as_deref() == Some(node_name)642+ && property_string(properties.get("media.class")).as_deref() == Some(media_class)643+ && match_properties.iter().all(|(key, expected)| {644+ property_string(properties.get(key)).is_some_and(|actual| actual == *expected)645+ })646+}647+648+fn property_string(value: Option<&Value>) -> Option<Cow<'_, str>> {649+ match value? {650+ Value::String(value) => Some(Cow::Borrowed(value)),651+ Value::Number(value) => Some(Cow::Owned(value.to_string())),652+ _ => None,653+ }654+}655+656+struct BoundedOutput {657+ status: ExitStatus,658+ stdout: Vec<u8>,659+ stderr: Vec<u8>,660+}661+662+async fn run_bounded_command(mut command: Command, stdout_limit: usize) -> Result<BoundedOutput> {663+ command664+ .stdin(Stdio::null())665+ .stdout(Stdio::piped())666+ .stderr(Stdio::piped())667+ .kill_on_drop(true);668+ let program = command.as_std().get_program().to_owned();669+ let mut child = command670+ .spawn()671+ .with_context(|| format!("starting {}", program.to_string_lossy()))?;672+ let stdout = child.stdout.take().context("child stdout was not piped")?;673+ let stderr = child.stderr.take().context("child stderr was not piped")?;674+675+ let collect = async {676+ let (stdout, stderr) = tokio::join!(677+ read_bounded(stdout, stdout_limit),678+ read_bounded(stderr, MAX_DIAGNOSTIC_OUTPUT)679+ );680+ let status = child681+ .wait()682+ .await683+ .context("waiting for preflight command")?;684+ Ok::<_, anyhow::Error>(BoundedOutput {685+ status,686+ stdout: stdout?,687+ stderr: stderr?,688+ })689+ };690+ match tokio::time::timeout(PREFLIGHT_TIMEOUT, collect).await {691+ Ok(result) => result,692+ Err(_) => {693+ let _ = child.kill().await;694+ let _ = child.wait().await;695+ bail!(696+ "preflight command {} exceeded {} seconds",697+ program.to_string_lossy(),698+ PREFLIGHT_TIMEOUT.as_secs()699+ )700+ }701+ }702+}703+704+async fn read_bounded<R>(reader: R, limit: usize) -> Result<Vec<u8>>705+where706+ R: AsyncRead + Unpin,707+{708+ let mut bytes = Vec::new();709+ reader710+ .take(limit.saturating_add(1) as u64)711+ .read_to_end(&mut bytes)712+ .await713+ .context("reading bounded command output")?;714+ ensure!(715+ bytes.len() <= limit,716+ "command output exceeded {limit} bytes"717+ );718+ Ok(bytes)719+}720+721+fn diagnostic(bytes: &[u8]) -> String {722+ String::from_utf8_lossy(bytes).trim().to_owned()723+}724+725+fn is_h264_keyframe(data: &[u8]) -> bool {726+ h264_nal_types(data).any(|nal_type| nal_type == 5)727+}728+729+fn has_h264_codec_config(data: &[u8]) -> bool {730+ let mut has_sps = false;731+ let mut has_pps = false;732+ for nal_type in h264_nal_types(data) {733+ has_sps |= nal_type == 7;734+ has_pps |= nal_type == 8;735+ }736+ has_sps && has_pps737+}738+739+fn h264_nal_types(data: &[u8]) -> impl Iterator<Item = u8> + '_ {740+ let mut index = 0;741+ std::iter::from_fn(move || {742+ while index + 4 <= data.len() {743+ let nal_offset = if data.get(index..index + 4) == Some(&[0, 0, 0, 1]) {744+ Some(index + 4)745+ } else if data.get(index..index + 3) == Some(&[0, 0, 1]) {746+ Some(index + 3)747+ } else {748+ None749+ };750+ index += 1;751+ if let Some(nal_offset) = nal_offset752+ && let Some(byte) = data.get(nal_offset)753+ {754+ return Some(byte & 0x1f);755+ }756+ }757+ None758+ })759+}760+761+762+mod tests {763+ use super::*;764+ use crate::config::{GamescopeEncoderBackend, InputMode, VideoSource};765+ use std::collections::BTreeMap;766+ use std::path::PathBuf;767+768+ pub(super) fn gamescope_config() -> HostConfig {769+ HostConfig {770+ identity_path: PathBuf::from("/tmp/host.key"),771+ state_path: PathBuf::from("/tmp/state"),772+ source: VideoSource::GamescopePipewire,773+ width: Some(1280),774+ height: Some(800),775+ framerate: 60,776+ codec: "h264".into(),777+ input_mode: InputMode::Disabled,778+ uinput: None,779+ ffmpeg_path: PathBuf::from("/usr/bin/ffmpeg"),780+ gamescope_pipewire: Some(GamescopePipewireConfig {781+ encoder_backend: GamescopeEncoderBackend::ExternalGstLaunch,782+ xwayland_display: None,783+ node_name: "gamescope".into(),784+ media_class: "Video/Source".into(),785+ match_properties: BTreeMap::from([(786+ "device.bus-path".into(),787+ "pci-0000:04:00.0".into(),788+ )]),789+ pw_dump_path: PathBuf::from("/usr/bin/pw-dump"),790+ gst_launch_path: PathBuf::from("/usr/bin/gst-launch-1.0"),791+ gst_inspect_path: PathBuf::from("/usr/bin/gst-inspect-1.0"),792+ vaapi_encoder: "vah264enc".into(),793+ vaapi_render_node: PathBuf::from("/dev/dri/renderD128"),794+ rate_control: VaapiRateControl::Cbr,795+ bitrate_kbps: Some(12_000),796+ quantizer: None,797+ }),798+ audio: None,799+ }800+ }801+802+ pub(super) fn configured_video_mode() -> CaptureVideoMode {803+ CaptureVideoMode {804+ width: 1_280,805+ height: 800,806+ framerate: 60,807+ }808+ }809+810+ fn gamescope_dump(serial: u64, width: u16, height: u16) -> Vec<u8> {811+ serde_json::to_vec(&serde_json::json!([{812+ "type": "PipeWire:Interface:Node",813+ "info": {814+ "props": {815+ "node.name": "gamescope",816+ "media.class": "Video/Source",817+ "device.bus-path": "pci-0000:04:00.0",818+ "object.serial": serial,819+ },820+ "params": {821+ "EnumFormat": [{822+ "mediaType": "video",823+ "mediaSubtype": "raw",824+ "format": "NV12",825+ "size": { "width": width, "height": height },826+ }],827+ },828+ },829+ }]))830+ .unwrap()831+ }832+833+ 834+ fn detects_h264_keyframes() {835+ assert!(is_h264_keyframe(&[0, 0, 0, 1, 5, 1, 2]));836+ assert!(!is_h264_keyframe(&[0, 0, 1, 7, 1, 2]));837+ assert!(!is_h264_keyframe(&[0, 0, 0, 1, 1, 1, 2]));838+ }839+840+ 841+ fn codec_config_requires_sps_and_pps() {842+ assert!(has_h264_codec_config(&[843+ 0, 0, 0, 1, 7, 1, 2, 0, 0, 1, 8, 3, 4844+ ]));845+ assert!(!has_h264_codec_config(&[0, 0, 1, 7, 1, 2]));846+ assert!(!has_h264_codec_config(&[0, 0, 1, 8, 1, 2]));847+ }848+849+ 850+ fn publisher_carries_exact_resolution_and_discontinuity_into_new_gop() {851+ let (frame_sender, frame_receiver) = watch::channel(None);852+ let (current_gop_sender, current_gop_receiver) = watch::channel(None);853+ let mut publisher = AccessUnitPublisher::new(854+ SessionClock::start(),855+ &frame_sender,856+ ¤t_gop_sender,857+ 30,858+ 1280,859+ 800,860+ );861+ let configured_idr = vec![0, 0, 0, 1, 7, 1, 0, 0, 0, 1, 8, 1, 0, 0, 0, 1, 5, 1];862+863+ assert!(864+ publisher865+ .publish_with_metadata(configured_idr, 960, 600, true)866+ .unwrap()867+ );868+ let frame = frame_receiver.borrow().as_ref().unwrap().clone();869+ assert_eq!((frame.width, frame.height), (960, 600));870+ assert!(frame.discontinuity);871+ let current_gop = current_gop_receiver.borrow();872+ let first = ¤t_gop.as_ref().unwrap().frames[0];873+ assert_eq!((first.width, first.height), (960, 600));874+ assert!(first.keyframe && first.codec_config && first.discontinuity);875+ }876+877+ 878+ fn current_gop_retains_only_a_bounded_contiguous_decodable_chain() {879+ let (frame_sender, frame_receiver) = watch::channel(None);880+ let (current_gop_sender, current_gop_receiver) = watch::channel(None);881+ let frame = |sequence, keyframe, codec_config, payload_len| EncodedFrame {882+ sequence,883+ width: 1280,884+ height: 800,885+ capture_timestamp_micros: sequence,886+ presentation_timestamp_micros: sequence as i64,887+ observed_at: Instant::now(),888+ keyframe,889+ codec_config,890+ discontinuity: false,891+ data: Arc::from(vec![sequence as u8; payload_len]),892+ };893+894+ for encoded in [895+ frame(10, true, true, 1),896+ frame(11, false, false, 1),897+ frame(12, false, false, 1),898+ ] {899+ publish_encoded_frame(&frame_sender, ¤t_gop_sender, encoded, 4, 8);900+ }901+902+ assert_eq!(frame_receiver.borrow().as_ref().unwrap().sequence, 12);903+ assert_eq!(904+ current_gop_receiver905+ .borrow()906+ .as_ref()907+ .unwrap()908+ .frames909+ .iter()910+ .map(|frame| frame.sequence)911+ .collect::<Vec<_>>(),912+ vec![10, 11, 12]913+ );914+915+ publish_encoded_frame(916+ &frame_sender,917+ ¤t_gop_sender,918+ frame(40, true, true, 1),919+ 4,920+ 8,921+ );922+ assert_eq!(923+ current_gop_receiver.borrow().as_ref().unwrap().frames[0].sequence,924+ 40925+ );926+927+ publish_encoded_frame(928+ &frame_sender,929+ ¤t_gop_sender,930+ frame(42, false, false, 1),931+ 4,932+ 8,933+ );934+ assert!(current_gop_receiver.borrow().is_none());935+936+ publish_encoded_frame(937+ &frame_sender,938+ ¤t_gop_sender,939+ frame(50, true, true, 3),940+ 4,941+ 4,942+ );943+ publish_encoded_frame(944+ &frame_sender,945+ ¤t_gop_sender,946+ frame(51, false, false, 2),947+ 4,948+ 4,949+ );950+ assert!(current_gop_receiver.borrow().is_none());951+952+ for encoded in [953+ frame(60, true, true, 1),954+ frame(61, false, false, 1),955+ frame(62, false, false, 1),956+ ] {957+ publish_encoded_frame(&frame_sender, ¤t_gop_sender, encoded, 2, 8);958+ }959+ assert!(current_gop_receiver.borrow().is_none());960+ }961+962+ 963+ fn recovery_gop_is_bounded_to_half_a_second() {964+ assert_eq!(interactive_gop_frames(60), 30);965+ assert_eq!(interactive_gop_frames(59), 30);966+ assert_eq!(interactive_gop_frames(1), 1);967+ }968+969+ 970+ fn resolves_exactly_one_pipewire_node_by_properties() {971+ let config = gamescope_config();972+ let dump = br#"[973+ {"type":"PipeWire:Interface:Node","info":{"props":{974+ "node.name":"gamescope","media.class":"Video/Source",975+ "device.bus-path":"pci-0000:04:00.0","object.serial":731976+ },"params":{"EnumFormat":[977+ {"mediaType":"video","mediaSubtype":"raw","format":"BGRx",978+ "size":{"width":2560,"height":1600}},979+ {"mediaType":"video","mediaSubtype":"raw","format":"NV12",980+ "size":{"width":2560,"height":1600}}981+ ]}}},982+ {"type":"PipeWire:Interface:Node","info":{"props":{983+ "node.name":"gamescope","media.class":"Audio/Source",984+ "device.bus-path":"pci-0000:04:00.0","object.serial":732985+ }}}986+ ]"#;987+ assert_eq!(988+ resolve_pipewire_node(dump, &config).unwrap(),989+ GamescopePreflight {990+ target_object: "731".into(),991+ pointer_surface_dimensions: PointerSurfaceDimensions::new(2_560, 1_600).unwrap(),992+ video_mode: configured_video_mode(),993+ }994+ );995+996+ let duplicate = br#"[997+ {"type":"PipeWire:Interface:Node","info":{"props":{998+ "node.name":"gamescope","media.class":"Video/Source",999+ "device.bus-path":"pci-0000:04:00.0","object.serial":7311000+ }}},1001+ {"type":"PipeWire:Interface:Node","info":{"props":{1002+ "node.name":"gamescope","media.class":"Video/Source",1003+ "device.bus-path":"pci-0000:04:00.0","object.serial":"732"1004+ }}}1005+ ]"#;1006+ assert!(resolve_pipewire_node(duplicate, &config).is_err());1007+1008+ let mut native_config = config.clone();1009+ native_config.width = None;1010+ native_config.height = None;1011+ let native = resolve_pipewire_node(dump, &native_config).unwrap();1012+ assert_eq!(1013+ native.video_mode,1014+ CaptureVideoMode {1015+ width: 2_560,1016+ height: 1_600,1017+ framerate: 60,1018+ }1019+ );1020+ assert_eq!(native.pointer_surface_dimensions.width, 2_560);1021+ assert_eq!(native.pointer_surface_dimensions.height, 1_600);1022+1023+ let mut distorted = config.clone();1024+ distorted.width = Some(1_920);1025+ distorted.height = Some(1_080);1026+ assert!(1027+ resolve_pipewire_node(dump, &distorted)1028+ .unwrap_err()1029+ .to_string()1030+ .contains("must preserve native 2560x1600 aspect ratio")1031+ );1032+1033+ let portrait_dump = std::str::from_utf8(dump)1034+ .unwrap()1035+ .replace("2560", "WIDTH")1036+ .replace("1600", "2560")1037+ .replace("WIDTH", "1600");1038+ let portrait = resolve_pipewire_node(portrait_dump.as_bytes(), &native_config).unwrap();1039+ assert_eq!(1040+ (portrait.video_mode.width, portrait.video_mode.height),1041+ (1_600, 2_560)1042+ );1043+ }1044+1045+ 1046+ fn unconfigured_gamescope_mode_follows_each_resolved_session() {1047+ let mut config = gamescope_config();1048+ config.width = None;1049+ config.height = None;1050+1051+ for (serial, width, height) in [1052+ (731, 2_560, 1_600),1053+ (900, 1_280, 800),1054+ (901, 1_920, 1_080),1055+ (902, 1_366, 768),1056+ ] {1057+ let resolved =1058+ resolve_pipewire_node(&gamescope_dump(serial, width, height), &config).unwrap();1059+ assert_eq!(resolved.target_object, serial.to_string());1060+ assert_eq!(1061+ resolved.pointer_surface_dimensions,1062+ PointerSurfaceDimensions::new(width, height).unwrap()1063+ );1064+ assert_eq!(1065+ resolved.video_mode,1066+ CaptureVideoMode {1067+ width,1068+ height,1069+ framerate: 60,1070+ }1071+ );1072+ assert_eq!(config.configured_dimensions(), None);1073+ }1074+ }1075+1076+ 1077+ fn resolves_pinned_upstream_gamescope_pipewire_contract() {1078+ // Sanitized from the standard interface emitted by Valve Gamescope1079+ // commit 17baf4abd1ab3353fb705e4d0d023f84e870f7e8, src/pipewire.cpp1080+ // blob 76b3ea8cc8ff01c6635498cbafe38130e33215f2. This fixture proves1081+ // interface compatibility; it is not SteamOS hardware evidence.1082+ let dump = include_bytes!("../../tests/fixtures/upstream-gamescope-pipewire-contract.json");1083+ let mut config = gamescope_config();1084+ config.width = None;1085+ config.height = None;1086+ config1087+ .gamescope_pipewire1088+ .as_mut()1089+ .unwrap()1090+ .match_properties1091+ .clear();1092+1093+ let resolved = resolve_pipewire_node(dump, &config).unwrap();1094+ assert_eq!(resolved.target_object, "8842");1095+ assert_eq!(1096+ resolved.pointer_surface_dimensions,1097+ PointerSurfaceDimensions::new(1_920, 1_080).unwrap()1098+ );1099+ assert_eq!(1100+ resolved.video_mode,1101+ CaptureVideoMode {1102+ width: 1_920,1103+ height: 1_080,1104+ framerate: 60,1105+ }1106+ );1107+1108+ let args: Vec<String> =1109+ gamescope_pipeline_args(&config, &resolved.target_object, resolved.video_mode)1110+ .unwrap()1111+ .into_iter()1112+ .map(|arg| arg.into_string().unwrap())1113+ .collect();1114+ assert!(args.contains(&"target-object=8842".to_owned()));1115+ assert!(1116+ args.contains(1117+ &"video/x-raw,format=NV12,width=1920,height=1080,framerate=60/1".to_owned()1118+ )1119+ );1120+ assert!(args.contains(&"max-rate=60".to_owned()));1121+ }1122+1123+ 1124+ fn rejects_ambiguous_or_unbounded_gamescope_native_size() {1125+ let config = gamescope_config();1126+ let dump = br#"[{"type":"PipeWire:Interface:Node","info":{"props":{1127+ "node.name":"gamescope","media.class":"Video/Source",1128+ "device.bus-path":"pci-0000:04:00.0","object.serial":7311129+ },"params":{"EnumFormat":[1130+ {"mediaType":"video","mediaSubtype":"raw","size":{"width":2560,"height":1600}},1131+ {"mediaType":"video","mediaSubtype":"raw","size":{"width":1280,"height":800}}1132+ ]}}}]"#;1133+ assert!(resolve_pipewire_node(dump, &config).is_err());1134+1135+ let unbounded = std::str::from_utf8(dump)1136+ .unwrap()1137+ .replace("2560", "7681")1138+ .replace(1139+ r#",{"mediaType":"video","mediaSubtype":"raw","size":{"width":1280,"height":800}}"#,1140+ "",1141+ );1142+ assert!(resolve_pipewire_node(unbounded.as_bytes(), &config).is_err());1143+1144+ let mut native_config = config.clone();1145+ native_config.width = None;1146+ native_config.height = None;1147+ let odd = br#"[{"type":"PipeWire:Interface:Node","info":{"props":{1148+ "node.name":"gamescope","media.class":"Video/Source",1149+ "device.bus-path":"pci-0000:04:00.0","object.serial":7311150+ },"params":{"EnumFormat":[1151+ {"mediaType":"video","mediaSubtype":"raw","size":{"width":1365,"height":768}}1152+ ]}}}]"#;1153+ let error = resolve_pipewire_node(odd, &native_config).unwrap_err();1154+ assert!(error.to_string().contains("must be even"), "{error:#}");1155+ }1156+}@@ -0,0 +1,285 @@
1+use std::fs;2+3+use anyhow::{Context, Result, ensure};4+use tokio::process::Command;5+6+use crate::config::{7+ GamescopeEncoderBackend, GamescopePipewireConfig, HostConfig, VaapiRateControl,8+};9+10+use super::{diagnostic, gamescope_config, run_bounded_command};11+12+13+use std::os::unix::fs::{FileTypeExt, PermissionsExt};14+15+const MAX_INSPECT_OUTPUT: usize = 1024 * 1024;16+17+pub(super) async fn preflight_gamescope_static(config: &HostConfig) -> Result<()> {18+ let pipewire = gamescope_config(config)?;19+20+ for (name, path) in [21+ ("pw_dump_path", &pipewire.pw_dump_path),22+ ("gst_inspect_path", &pipewire.gst_inspect_path),23+ ] {24+ validate_executable(name, path)?;25+ }26+ if pipewire.encoder_backend == GamescopeEncoderBackend::ExternalGstLaunch {27+ validate_executable("gst_launch_path", &pipewire.gst_launch_path)?;28+ }29+ validate_amd_render_node(&pipewire.vaapi_render_node)?;30+31+ let terminal_sink = match pipewire.encoder_backend {32+ GamescopeEncoderBackend::ExternalGstLaunch => "fdsink",33+ GamescopeEncoderBackend::InProcessGstreamer => "appsink",34+ };35+ for element in [36+ "pipewiresrc",37+ "queue",38+ "videoconvert",39+ "videoscale",40+ "videorate",41+ "capsfilter",42+ pipewire.vaapi_encoder.as_str(),43+ "h264parse",44+ terminal_sink,45+ ] {46+ let mut command = Command::new(&pipewire.gst_inspect_path);47+ command.arg(element).env("LC_ALL", "C");48+ let output = run_bounded_command(command, MAX_INSPECT_OUTPUT).await?;49+ ensure!(50+ output.status.success(),51+ "GStreamer element {element:?} is unavailable: {}",52+ diagnostic(&output.stderr)53+ );54+ if element == pipewire.vaapi_encoder {55+ validate_encoder_inspection(&output.stdout, pipewire)?;56+ }57+ }58+59+ Ok(())60+}61+62+fn validate_encoder_inspection(output: &[u8], config: &GamescopePipewireConfig) -> Result<()> {63+ let output = std::str::from_utf8(output).context("gst-inspect output is not UTF-8")?;64+ let lines: Vec<&str> = output.lines().collect();65+ let device_block = gst_property_block(&lines, "device-path")66+ .context("configured VA encoder does not report a device-path property")?;67+ let expected = config.vaapi_render_node.to_string_lossy();68+ let expected_default = format!("Default: \"{expected}\"");69+ ensure!(70+ device_block.contains(&expected_default),71+ "configured VA encoder {:?} does not report expected device-path {}",72+ config.vaapi_encoder,73+ config.vaapi_render_node.display()74+ );75+ for property in [76+ "aud",77+ "b-frames",78+ "key-int-max",79+ "rate-control",80+ "ref-frames",81+ "target-usage",82+ ] {83+ let block = gst_property_block(&lines, property).with_context(|| {84+ format!(85+ "configured VA encoder {:?} has no {property} property",86+ config.vaapi_encoder87+ )88+ })?;89+ ensure!(90+ block.contains("writable") || block.contains("Writable"),91+ "configured VA encoder {:?} property {property} is not writable",92+ config.vaapi_encoder93+ );94+ }95+ let rate_control =96+ gst_property_block(&lines, "rate-control").expect("rate-control block was checked above");97+ let expected_rate_control = match config.rate_control {98+ VaapiRateControl::Cbr => "cbr",99+ VaapiRateControl::Cqp => "cqp",100+ };101+ ensure!(102+ rate_control.contains(expected_rate_control),103+ "configured VA encoder {:?} does not advertise {expected_rate_control} rate control",104+ config.vaapi_encoder105+ );106+ let mode_properties: &[&str] = match config.rate_control {107+ VaapiRateControl::Cbr => &["bitrate"],108+ VaapiRateControl::Cqp => &["qpi", "qpp"],109+ };110+ for property in mode_properties {111+ let block = gst_property_block(&lines, property).with_context(|| {112+ format!(113+ "configured VA encoder {:?} has no {property} property",114+ config.vaapi_encoder115+ )116+ })?;117+ ensure!(118+ block.contains("writable") || block.contains("Writable"),119+ "configured VA encoder {:?} property {property} is not writable",120+ config.vaapi_encoder121+ );122+ }123+ Ok(())124+}125+126+fn gst_property_block(lines: &[&str], property: &str) -> Option<String> {127+ let start = lines.iter().position(|line| {128+ line.strip_prefix(" ").is_some_and(|line| {129+ line.starts_with(property) && line[property.len()..].starts_with(' ')130+ })131+ })?;132+ let end = lines[start + 1..]133+ .iter()134+ .position(|line| {135+ line.strip_prefix(" ")136+ .is_some_and(|line| !line.starts_with(' ') && line.split_once(" : ").is_some())137+ })138+ .map_or(lines.len(), |offset| start + 1 + offset);139+ Some(lines[start..end].join("\n"))140+}141+142+pub(crate) fn validate_executable(name: &str, path: &std::path::Path) -> Result<()> {143+ let metadata = fs::symlink_metadata(path)144+ .with_context(|| format!("inspecting gamescope_pipewire.{name} at {}", path.display()))?;145+ ensure!(146+ !metadata.file_type().is_symlink(),147+ "gamescope_pipewire.{name} must not be a symlink"148+ );149+ ensure!(150+ metadata.is_file(),151+ "gamescope_pipewire.{name} is not a file"152+ );153+ 154+ {155+ use std::os::unix::fs::MetadataExt;156+157+ ensure!(158+ metadata.permissions().mode() & 0o111 != 0,159+ "gamescope_pipewire.{name} is not executable"160+ );161+ ensure!(162+ metadata.permissions().mode() & 0o022 == 0,163+ "gamescope_pipewire.{name} must not be writable by group or other users"164+ );165+ let owner = metadata.uid();166+ let effective_user = unsafe { libc::geteuid() };167+ ensure!(168+ owner == 0 || owner == effective_user,169+ "gamescope_pipewire.{name} must be owned by root or the current user"170+ );171+ }172+ Ok(())173+}174+175+pub(crate) async fn preflight_gstreamer_element(176+ gst_inspect_path: &std::path::Path,177+ element: &str,178+) -> Result<()> {179+ let mut command = Command::new(gst_inspect_path);180+ command.arg(element).env("LC_ALL", "C");181+ let output = run_bounded_command(command, MAX_INSPECT_OUTPUT).await?;182+ ensure!(183+ output.status.success(),184+ "GStreamer element {element:?} is unavailable: {}",185+ diagnostic(&output.stderr)186+ );187+ Ok(())188+}189+190+fn validate_amd_render_node(path: &std::path::Path) -> Result<()> {191+ let metadata = fs::symlink_metadata(path)192+ .with_context(|| format!("inspecting VAAPI render node {}", path.display()))?;193+ 194+ ensure!(195+ metadata.file_type().is_char_device(),196+ "VAAPI render node {} is not a character device",197+ path.display()198+ );199+ fs::OpenOptions::new()200+ .read(true)201+ .write(true)202+ .open(path)203+ .with_context(|| {204+ format!(205+ "opening VAAPI render node {} read/write as the current user",206+ path.display()207+ )208+ })?;209+ 210+ {211+ let name = path212+ .file_name()213+ .context("VAAPI render node has no file name")?;214+ let driver = fs::canonicalize(215+ std::path::Path::new("/sys/class/drm")216+ .join(name)217+ .join("device/driver"),218+ )219+ .with_context(|| format!("resolving kernel driver for {}", path.display()))?;220+ ensure!(221+ driver.file_name().and_then(|name| name.to_str()) == Some("amdgpu"),222+ "VAAPI render node {} is not backed by amdgpu",223+ path.display()224+ );225+ }226+ Ok(())227+}228+229+230+mod tests {231+ use super::super::tests::gamescope_config;232+ use super::*;233+234+ 235+ fn requires_encoder_to_report_configured_render_node() {236+ let config = gamescope_config();237+ let pipewire = config.gamescope_pipewire.as_ref().unwrap();238+ let inspect = br#"239+Element Properties:240+ device-path : The DRM device path used for VA operation241+ flags: readable242+ String. Default: "/dev/dri/renderD128"243+ aud : Insert an AU delimiter for each frame244+ flags: readable, writable245+ b-frames : Number of B-frames246+ flags: readable, writable247+ bitrate : Target bitrate248+ flags: readable, writable249+ key-int-max : Maximum keyframe distance250+ flags: readable, writable251+ rate-control : Rate control mode252+ flags: readable, writable253+ Enum. Default: cbr254+ (2): cbr - Constant Bitrate255+ ref-frames : Number of reference frames256+ flags: readable, writable257+ target-usage : Speed and quality tradeoff258+ flags: readable, writable259+"#;260+ validate_encoder_inspection(inspect, pipewire).unwrap();261+262+ let index = inspect263+ .windows(b"renderD128".len())264+ .position(|window| window == b"renderD128")265+ .unwrap();266+ let mut wrong = inspect.to_vec();267+ wrong[index..index + b"renderD128".len()].copy_from_slice(b"renderD129");268+ assert!(validate_encoder_inspection(&wrong, pipewire).is_err());269+270+ let mut cqp = gamescope_config();271+ let pipewire = cqp.gamescope_pipewire.as_mut().unwrap();272+ pipewire.vaapi_encoder = "vah264lpenc".into();273+ pipewire.rate_control = VaapiRateControl::Cqp;274+ pipewire.bitrate_kbps = None;275+ pipewire.quantizer = Some(24);276+ let cqp_inspect = String::from_utf8_lossy(inspect)277+ .replace("Default: cbr", "Default: cqp")278+ .replace("(2): cbr", "(16): cqp")279+ .replace(280+ " ref-frames",281+ " qpi : I-frame quantizer\n flags: readable, writable\n qpp : P-frame quantizer\n flags: readable, writable\n ref-frames",282+ );283+ validate_encoder_inspection(cqp_inspect.as_bytes(), pipewire).unwrap();284+ }285+}