diff --git a/crates/clipbin-obs/src/ffi.rs b/crates/clipbin-obs/src/ffi.rs index 8248763..f4eb268 100644 --- a/crates/clipbin-obs/src/ffi.rs +++ b/crates/clipbin-obs/src/ffi.rs @@ -176,6 +176,14 @@ fn pipewire_state(message: &str) -> Option { PipeWireState::Stopped }); } + // Requested from the portal, then the picker is shown (or a restore + // token is used): only the first step can stall without the user. + if message == "PipeWire initialized" { + return Some(PipeWireState::Requesting); + } + if message.starts_with("[pipewire] Asking for ") { + return Some(PipeWireState::Selecting); + } // The user denied or cancelled the portal request, or it failed. let refused = message.starts_with("[pipewire] Error ") || (message.starts_with("[pipewire] Failed to ") @@ -185,6 +193,8 @@ fn pipewire_state(message: &str) -> Option { fn load_pipewire_state() -> Option { match PIPEWIRE_STATE.load(Ordering::Relaxed) { + state if state == PipeWireState::Requesting as u8 => Some(PipeWireState::Requesting), + state if state == PipeWireState::Selecting as u8 => Some(PipeWireState::Selecting), state if state == PipeWireState::Streaming as u8 => Some(PipeWireState::Streaming), state if state == PipeWireState::Stopped as u8 => Some(PipeWireState::Stopped), state if state == PipeWireState::Refused as u8 => Some(PipeWireState::Refused), @@ -877,6 +887,14 @@ mod tests { state("[pipewire] Error selecting screencast source: Timeout"), Some(PipeWireState::Refused) ); + assert_eq!( + state("PipeWire initialized"), + Some(PipeWireState::Requesting) + ); + assert_eq!( + state("[pipewire] Asking for monitor and window"), + Some(PipeWireState::Selecting) + ); assert_eq!(state("[pipewire] Screencast session created"), None); // OBS cancelling its own pending request at teardown is not a refusal. assert_eq!(state("[portals] Request cancelled"), None); diff --git a/crates/clipbin-obs/src/recorder.rs b/crates/clipbin-obs/src/recorder.rs index 1505f64..4c91862 100644 --- a/crates/clipbin-obs/src/recorder.rs +++ b/crates/clipbin-obs/src/recorder.rs @@ -286,11 +286,15 @@ pub struct Health { #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum PipeWireState { - Streaming = 1, + /// A screen-share session was requested from the portal. + Requesting = 1, + /// The portal is showing its picker or restoring an earlier selection. + Selecting = 2, + Streaming = 3, /// The stream exists but is not delivering frames. - Stopped = 2, + Stopped = 4, /// The portal request was denied, cancelled by the user, or failed. - Refused = 3, + Refused = 5, } /// Derives the status shown to the user from health samples and output events. @@ -305,6 +309,7 @@ pub struct StatusTracker { stall_limit: Duration, last_written: u64, last_progress: Instant, + requesting_since: Option, } impl StatusTracker { @@ -312,6 +317,9 @@ impl StatusTracker { /// be finalised rather than failing mid-write. pub const DEFAULT_MIN_FREE_BYTES: u64 = 1 << 30; + /// How long the portal may take to reach its picker or restore a selection. + pub const PORTAL_RESPONSE_LIMIT: Duration = Duration::from_secs(10); + /// Matroska output can stay buffered until its segment closes, so footage /// may appear on disk only once per segment. A stall is declared after two /// segment lengths without growth. @@ -322,6 +330,7 @@ impl StatusTracker { stall_limit: (segment_length * 2 + Duration::from_secs(1)).max(Duration::from_secs(3)), last_written: 0, last_progress: now, + requesting_since: None, } } @@ -343,6 +352,21 @@ impl StatusTracker { self.last_written = health.written_bytes; self.last_progress = now; } + // Before the picker appears the portal needs no user input, so a + // request stuck there has stalled (seen with Hyprland's portal). + if health.pipewire == Some(PipeWireState::Requesting) { + let since = *self.requesting_since.get_or_insert(now); + if now.duration_since(since) >= Self::PORTAL_RESPONSE_LIMIT { + self.status = RecordingStatus::Failed { + reason: + "the screen-share portal did not respond; select a capture source again" + .into(), + }; + return &self.status; + } + } else { + self.requesting_since = None; + } if health.pipewire == Some(PipeWireState::Refused) { self.status = RecordingStatus::Failed { reason: "screen capture was not allowed: the source selection was denied, cancelled or failed" @@ -618,6 +642,38 @@ mod tests { )); } + #[test] + fn a_portal_that_never_reaches_its_picker_fails() { + let start = Instant::now(); + let mut tracker = StatusTracker::new(start, 0, SEGMENT); + let requesting = Health { + source_width: 0, + source_height: 0, + pipewire: Some(PipeWireState::Requesting), + ..healthy(5000) + }; + assert_eq!( + tracker.sample(&requesting, start), + &RecordingStatus::WaitingForCapture + ); + // The user may take as long as they like in the picker. + let selecting = Health { + pipewire: Some(PipeWireState::Selecting), + ..requesting.clone() + }; + let much_later = start + StatusTracker::PORTAL_RESPONSE_LIMIT * 3; + assert_eq!( + tracker.sample(&selecting, much_later), + &RecordingStatus::WaitingForCapture + ); + let mut tracker = StatusTracker::new(start, 0, SEGMENT); + tracker.sample(&requesting, start); + assert!(matches!( + tracker.sample(&requesting, start + StatusTracker::PORTAL_RESPONSE_LIMIT), + RecordingStatus::Failed { reason } if reason.contains("did not respond") + )); + } + #[test] fn output_errors_fail_and_clean_stops_go_idle() { let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT); diff --git a/crates/clipbin-probe/src/main.rs b/crates/clipbin-probe/src/main.rs index 66c0a3e..6c15c09 100644 --- a/crates/clipbin-probe/src/main.rs +++ b/crates/clipbin-probe/src/main.rs @@ -470,24 +470,24 @@ fn check(options: &Options) -> Result> { } // A black or frozen capture still produces valid segments. When the - // captured content was moving, the middle segment must show it. + // captured content was moving, some segment must show it (capture may + // start late, after the user answers the picker). if options.expect_motion { - let middle = &segments[segments.len() / 2]; - let distinct = Command::new("ffmpeg") - .args(["-v", "error", "-i"]) - .arg(middle) - .args(["-map", "0:v:0", "-vf", "mpdecimate=max=0", "-f", "null", "-", "-stats_period", "100"]) - .args(["-progress", "pipe:1"]) - .output()?; - let frames = String::from_utf8_lossy(&distinct.stdout) - .lines() - .filter_map(|line| line.strip_prefix("frame=")) - .filter_map(|frames| frames.parse::().ok()) - .last() - .unwrap_or(0); - let moving = frames > 1; + let mut most = (0, None); + for segment in &segments { + let frames = distinct_frames(segment)?; + if frames > most.0 { + most = (frames, Some(segment)); + } + } + let moving = most.0 > 1; ok &= moving; - println!("motion ok={moving} distinct_frames={frames} segment={}", middle.display()); + println!( + "motion ok={moving} distinct_frames={} segment={}", + most.0, + most.1 + .map_or(String::new(), |segment| segment.display().to_string()) + ); } // A clip spanning the boundaries between the first three segments. @@ -540,6 +540,30 @@ fn check(options: &Options) -> Result> { Ok(ok) } +/// Frames in a segment's video that differ from the previous one. +fn distinct_frames(segment: &Path) -> Result> { + let output = Command::new("ffmpeg") + .args(["-v", "error", "-i"]) + .arg(segment) + .args([ + "-map", + "0:v:0", + "-vf", + "mpdecimate=max=0", + "-f", + "null", + "-", + ]) + .args(["-progress", "pipe:1"]) + .output()?; + Ok(String::from_utf8_lossy(&output.stdout) + .lines() + .filter_map(|line| line.strip_prefix("frame=")) + .filter_map(|frames| frames.parse().ok()) + .next_back() + .unwrap_or(0)) +} + fn ffprobe(path: &Path, query: &[&str]) -> Result> { let output = Command::new("ffprobe") .args(["-v", "error"]) diff --git a/docs/validation.md b/docs/validation.md index de1a5bc..9a47008 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -9,7 +9,7 @@ pass/fail results. | Where | Script | Status | | --- | --- | --- | | Linux, headless rig (Wayland portal + Xwayland) | `scripts/validate/linux-rig.sh` | All 9 scenarios pass on devbox (Intel UHD 770, VA-API) | -| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | First run 2026-10-08: H.264, HEVC and AV1 all usable; restore token still to confirm | +| Linux, real session (CachyOS, Hyprland, RX 9070 XT, 1440p60) | `scripts/validate/linux-hardware.sh` | 2026-10-08: H.264, HEVC and AV1 usable; restore token works; portal stall fix awaiting re-run | | Windows (Game Capture, NVENC/AMF/QSV) | `scripts/validate/windows.ps1` | Not run yet; script untested | ## Probe commands @@ -77,6 +77,20 @@ CachyOS 7.2.9, Hyprland, RX 9070 XT, 1440p60, OBS 32.0.4: the PipeWire stream `streaming` and OBS repeats the window's last frame. See findings. +### Second hardware run (2026-10-08) + +- The restore token works: the restored run started recording in 2 s without + showing the picker. +- The portal stalled silently on one run again: it created the session but + never answered `SelectSources`, before any picker. Recording now fails if + the portal has not reached its picker 10 s after the request + (`StatusTracker::PORTAL_RESPONSE_LIMIT`); time spent in the picker itself + is not limited. +- The motion check sampled a segment from before capture started (the user + took 16 s in the picker); it now uses the segment with the most motion. +- A portrait monitor (Hyprland transform 3) is captured as 1440x2560 and + pillarboxed in the 2560x1440 canvas. + ## Windows Run `scripts/validate/windows.ps1 -GameWindow ":<class>:<exe>"` with a @@ -133,6 +147,9 @@ Windows status: has a picture, so a source that never arrives produces black segments. The session timeline should show that time as missing footage, or the output should start only once capture is usable. +- **Canvas orientation.** The output canvas is fixed by the configured + resolution, so portrait or other-aspect sources are letterboxed. Decide + whether the canvas should follow the captured source. - **Audio capture failures are not detected yet.** When the PulseAudio server was unreachable, recording continued with a silent track. - **Linux runtime dependencies.** The Linux OBS build links the system's