fix: fail on refused portal requests and catch black recordings
Rust checks / windows-cross-check (push) Failing after 40s
Rust checks / linux (push) Failing after 5m38s

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:
2026-10-08 15:01:25 +02:00
parent 91f119e6b6
commit 06377de707
7 changed files with 208 additions and 83 deletions
+27 -3
View File
@@ -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]