fix: fail on refused portal requests and catch black recordings
The portal reports refusals only in OBS's log; a denied, cancelled or failed source selection now fails with an actionable reason instead of waiting forever. check --expect-motion rejects black or frozen footage, and the hardware script checks only runs that recorded, tests restore tokens in their own step, and treats closing a window as informational: on Hyprland the stream keeps its last frame, so sessions must end on game exit.
This commit is contained in:
@@ -19,7 +19,7 @@ use libobs as sys;
|
||||
|
||||
use crate::{
|
||||
capture::{SettingValue, SourceSpec},
|
||||
recorder::{Health, RecorderEvent},
|
||||
recorder::{Health, PipeWireState, RecorderEvent},
|
||||
};
|
||||
|
||||
fn cstring(value: &str) -> CString {
|
||||
@@ -149,13 +149,8 @@ unsafe extern "C" fn log_to_stderr(
|
||||
_ => "debug",
|
||||
};
|
||||
if let Some(text) = owned(message.array) {
|
||||
if let Some(state) = pipewire_stream_state(&text) {
|
||||
let code = if state == "streaming" {
|
||||
STREAMING
|
||||
} else {
|
||||
NOT_STREAMING
|
||||
};
|
||||
PIPEWIRE_STREAM.store(code, Ordering::Relaxed);
|
||||
if let Some(state) = pipewire_state(&text) {
|
||||
PIPEWIRE_STATE.store(state as u8, Ordering::Relaxed);
|
||||
}
|
||||
eprintln!("obs {label}: {text}");
|
||||
}
|
||||
@@ -163,22 +158,38 @@ unsafe extern "C" fn log_to_stderr(
|
||||
unsafe { sys::bfree(message.array.cast()) };
|
||||
}
|
||||
|
||||
/// The state of the PipeWire capture stream, as last logged by OBS.
|
||||
/// The PipeWire capture state, as last logged by OBS (0 = nothing logged).
|
||||
///
|
||||
/// OBS 32.0.4's PipeWire capture keeps reporting its last size and rendering
|
||||
/// its last frame after the stream stops, and exposes the stream state only
|
||||
/// through this log line. Clipbin records one capture source at a time.
|
||||
static PIPEWIRE_STREAM: AtomicU8 = AtomicU8::new(NO_PIPEWIRE_STREAM);
|
||||
const NO_PIPEWIRE_STREAM: u8 = 0;
|
||||
const STREAMING: u8 = 1;
|
||||
const NOT_STREAMING: u8 = 2;
|
||||
/// its last frame after its stream stops, and reports portal refusals and
|
||||
/// stream states only in its log (linux-pipewire's screencast-portal.c and
|
||||
/// pipewire.c). Clipbin records one capture source at a time.
|
||||
static PIPEWIRE_STATE: AtomicU8 = AtomicU8::new(0);
|
||||
|
||||
/// Parses `[pipewire] Stream 0x... state: "streaming" (error: none)` from
|
||||
/// linux-pipewire's on_state_changed_cb.
|
||||
fn pipewire_stream_state(message: &str) -> Option<&str> {
|
||||
let rest = message.strip_prefix("[pipewire] Stream ")?;
|
||||
let state = rest.split_once(" state: \"")?.1;
|
||||
state.split_once('"').map(|(state, _)| state)
|
||||
fn pipewire_state(message: &str) -> Option<PipeWireState> {
|
||||
if let Some(rest) = message.strip_prefix("[pipewire] Stream ") {
|
||||
// `[pipewire] Stream 0x... state: "streaming" (error: none)`
|
||||
let state = rest.split_once(" state: \"")?.1.split_once('"')?.0;
|
||||
return Some(if state == "streaming" {
|
||||
PipeWireState::Streaming
|
||||
} else {
|
||||
PipeWireState::Stopped
|
||||
});
|
||||
}
|
||||
// The user denied or cancelled the portal request, or it failed.
|
||||
let refused = message.starts_with("[pipewire] Error ")
|
||||
|| (message.starts_with("[pipewire] Failed to ")
|
||||
&& message.ends_with("denied or cancelled by user"));
|
||||
refused.then_some(PipeWireState::Refused)
|
||||
}
|
||||
|
||||
fn load_pipewire_state() -> Option<PipeWireState> {
|
||||
match PIPEWIRE_STATE.load(Ordering::Relaxed) {
|
||||
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),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
|
||||
@@ -568,7 +579,7 @@ impl Pipeline {
|
||||
events: Sender<RecorderEvent>,
|
||||
current_segment: Arc<Mutex<Option<String>>>,
|
||||
) -> Result<Self, String> {
|
||||
PIPEWIRE_STREAM.store(NO_PIPEWIRE_STREAM, Ordering::Relaxed);
|
||||
PIPEWIRE_STATE.store(0, Ordering::Relaxed);
|
||||
// OBS releases objects asynchronously, so names must not be reused.
|
||||
static RECORDINGS: AtomicU64 = AtomicU64::new(0);
|
||||
let id = RECORDINGS.fetch_add(1, Ordering::Relaxed);
|
||||
@@ -745,14 +756,10 @@ impl Pipeline {
|
||||
sys::obs_source_get_height(self.video_source),
|
||||
)
|
||||
};
|
||||
let source_streaming = match PIPEWIRE_STREAM.load(Ordering::Relaxed) {
|
||||
NO_PIPEWIRE_STREAM => None,
|
||||
state => Some(state == STREAMING),
|
||||
};
|
||||
Health {
|
||||
source_width: width,
|
||||
source_height: height,
|
||||
source_streaming,
|
||||
pipewire: load_pipewire_state(),
|
||||
output_active: sys::obs_output_active(self.output),
|
||||
total_frames: sys::obs_output_get_total_frames(self.output).max(0) as u64,
|
||||
dropped_frames: sys::obs_output_get_frames_dropped(self.output).max(0) as u64,
|
||||
@@ -852,13 +859,27 @@ mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn pipewire_stream_state_is_read_from_the_plugin_log() {
|
||||
let line = r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#;
|
||||
assert_eq!(pipewire_stream_state(line), Some("paused"));
|
||||
fn pipewire_state_is_read_from_the_plugin_log() {
|
||||
let state = |line| pipewire_state(line);
|
||||
assert_eq!(
|
||||
pipewire_stream_state("[pipewire] Screencast session created"),
|
||||
None
|
||||
state(r#"[pipewire] Stream 0x7fe128037440 state: "streaming" (error: none)"#),
|
||||
Some(PipeWireState::Streaming)
|
||||
);
|
||||
assert_eq!(
|
||||
state(r#"[pipewire] Stream 0x7fe128037440 state: "paused" (error: none)"#),
|
||||
Some(PipeWireState::Stopped)
|
||||
);
|
||||
assert_eq!(
|
||||
state("[pipewire] Failed to select source, denied or cancelled by user"),
|
||||
Some(PipeWireState::Refused)
|
||||
);
|
||||
assert_eq!(
|
||||
state("[pipewire] Error selecting screencast source: Timeout"),
|
||||
Some(PipeWireState::Refused)
|
||||
);
|
||||
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);
|
||||
}
|
||||
|
||||
#[test]
|
||||
|
||||
@@ -12,7 +12,7 @@ pub mod runtime;
|
||||
mod thread;
|
||||
|
||||
pub use recorder::{
|
||||
Engine, EngineError, Health, RecorderEvent, Recording, RecordingConfig, SEGMENT_EXTENSION,
|
||||
StatusTracker, StopReason, VideoFormat,
|
||||
Engine, EngineError, Health, PipeWireState, RecorderEvent, Recording, RecordingConfig,
|
||||
SEGMENT_EXTENSION, StatusTracker, StopReason, VideoFormat,
|
||||
};
|
||||
pub use runtime::{RuntimeError, RuntimeLayout};
|
||||
|
||||
@@ -270,9 +270,9 @@ pub enum RecorderEvent {
|
||||
pub struct Health {
|
||||
pub source_width: u32,
|
||||
pub source_height: u32,
|
||||
/// For PipeWire capture, whether its stream is delivering frames. `None`
|
||||
/// for sources that report loss through their size instead.
|
||||
pub source_streaming: Option<bool>,
|
||||
/// PipeWire capture's portal and stream state. `None` for other sources,
|
||||
/// which report loss through their size, and before the portal answers.
|
||||
pub pipewire: Option<PipeWireState>,
|
||||
pub output_active: bool,
|
||||
pub total_frames: u64,
|
||||
pub dropped_frames: u64,
|
||||
@@ -284,6 +284,15 @@ pub struct Health {
|
||||
pub disk_available_bytes: Option<u64>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum PipeWireState {
|
||||
Streaming = 1,
|
||||
/// The stream exists but is not delivering frames.
|
||||
Stopped = 2,
|
||||
/// The portal request was denied, cancelled by the user, or failed.
|
||||
Refused = 3,
|
||||
}
|
||||
|
||||
/// Derives the status shown to the user from health samples and output events.
|
||||
///
|
||||
/// Recording is reported only while the source has a picture and encoded data
|
||||
@@ -334,9 +343,16 @@ impl StatusTracker {
|
||||
self.last_written = health.written_bytes;
|
||||
self.last_progress = now;
|
||||
}
|
||||
if health.pipewire == Some(PipeWireState::Refused) {
|
||||
self.status = RecordingStatus::Failed {
|
||||
reason: "screen capture was not allowed: the source selection was denied, cancelled or failed"
|
||||
.into(),
|
||||
};
|
||||
return &self.status;
|
||||
}
|
||||
let has_picture = health.source_width > 0
|
||||
&& health.source_height > 0
|
||||
&& health.source_streaming != Some(false);
|
||||
&& matches!(health.pipewire, None | Some(PipeWireState::Streaming));
|
||||
let stalled = now.duration_since(self.last_progress) >= self.stall_limit;
|
||||
if let Some(available) = health.disk_available_bytes
|
||||
&& available < self.min_free_bytes
|
||||
@@ -450,7 +466,7 @@ mod tests {
|
||||
Health {
|
||||
source_width: 1920,
|
||||
source_height: 1080,
|
||||
source_streaming: None,
|
||||
pipewire: None,
|
||||
output_active: true,
|
||||
total_frames: bytes / 1000,
|
||||
dropped_frames: 0,
|
||||
@@ -562,7 +578,7 @@ mod tests {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
let streaming = Health {
|
||||
source_streaming: Some(true),
|
||||
pipewire: Some(PipeWireState::Streaming),
|
||||
..healthy(5000)
|
||||
};
|
||||
assert_eq!(
|
||||
@@ -570,7 +586,7 @@ mod tests {
|
||||
&RecordingStatus::Recording
|
||||
);
|
||||
let paused = Health {
|
||||
source_streaming: Some(false),
|
||||
pipewire: Some(PipeWireState::Stopped),
|
||||
..healthy(9000)
|
||||
};
|
||||
assert!(matches!(
|
||||
@@ -579,6 +595,29 @@ mod tests {
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_refused_portal_request_fails_while_waiting_for_capture() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start, 0, SEGMENT);
|
||||
let waiting = Health {
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
..healthy(5000)
|
||||
};
|
||||
assert_eq!(
|
||||
tracker.sample(&waiting, start),
|
||||
&RecordingStatus::WaitingForCapture
|
||||
);
|
||||
let refused = Health {
|
||||
pipewire: Some(PipeWireState::Refused),
|
||||
..waiting
|
||||
};
|
||||
assert!(matches!(
|
||||
tracker.sample(&refused, start),
|
||||
RecordingStatus::Failed { reason } if reason.contains("not allowed")
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_errors_fail_and_clean_stops_go_idle() {
|
||||
let mut tracker = StatusTracker::new(Instant::now(), 0, SEGMENT);
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
//! [--seconds N] [--segment-seconds N] [--keyint N] [--bitrate KBPS]
|
||||
//! [--width W --height H --fps F] [--no-audio] [--mic]
|
||||
//! [--min-free-mb MB] [--out DIR]
|
||||
//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--out DIR]
|
||||
//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--expect-motion] [--out DIR]
|
||||
//!
|
||||
//! `check` inspects the segments `record` wrote, using the ffprobe and ffmpeg
|
||||
//! command-line tools (install FFmpeg; on Windows, e.g. `winget install ffmpeg`).
|
||||
@@ -63,6 +63,7 @@ fn usage(error: &str) -> ExitCode {
|
||||
|
||||
struct Options {
|
||||
verify: bool,
|
||||
expect_motion: bool,
|
||||
target: String,
|
||||
window: String,
|
||||
restore_token: Option<String>,
|
||||
@@ -83,6 +84,7 @@ impl Options {
|
||||
fn parse(args: &[String]) -> Result<Self, String> {
|
||||
let mut options = Self {
|
||||
verify: false,
|
||||
expect_motion: false,
|
||||
target: "monitor".into(),
|
||||
window: String::new(),
|
||||
restore_token: None,
|
||||
@@ -112,6 +114,7 @@ impl Options {
|
||||
};
|
||||
match flag.as_str() {
|
||||
"--verify" => options.verify = true,
|
||||
"--expect-motion" => options.expect_motion = true,
|
||||
"--target" => options.target = value()?,
|
||||
"--window" => options.window = value()?,
|
||||
"--restore-token" => options.restore_token = Some(value()?),
|
||||
@@ -337,11 +340,11 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
next_sample += Duration::from_secs(1);
|
||||
let health = recording.health();
|
||||
println!(
|
||||
"t={} health source={}x{} streaming={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}",
|
||||
"t={} health source={}x{} pipewire={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}",
|
||||
elapsed(),
|
||||
health.source_width,
|
||||
health.source_height,
|
||||
health.source_streaming,
|
||||
health.pipewire,
|
||||
health.output_active,
|
||||
health.total_frames,
|
||||
health.dropped_frames,
|
||||
@@ -466,6 +469,27 @@ fn check(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
);
|
||||
}
|
||||
|
||||
// A black or frozen capture still produces valid segments. When the
|
||||
// captured content was moving, the middle segment must show it.
|
||||
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::<u64>().ok())
|
||||
.last()
|
||||
.unwrap_or(0);
|
||||
let moving = frames > 1;
|
||||
ok &= moving;
|
||||
println!("motion ok={moving} distinct_frames={frames} segment={}", middle.display());
|
||||
}
|
||||
|
||||
// A clip spanning the boundaries between the first three segments.
|
||||
let list = options.out.join("concat.txt");
|
||||
let entries: String = segments[..3]
|
||||
|
||||
Reference in New Issue
Block a user