feat: detect lost sources, stalled writes and low disk space
PipeWire capture keeps its size and last frame after its stream stops, so the recorder follows the stream state OBS logs. Recording fails when the segment on disk stops growing (OBS kept counting bytes into a dead muxer and missed a full disk) and stops while enough space remains to finalise the last segment. Failures stay visible after the output stops. Adds a check command that verifies keyframe-aligned segments and a remux across segment boundaries, and gives the rig desktop audio.
This commit is contained in:
@@ -7,12 +7,17 @@
|
||||
//! clipbin-probe record [--target monitor|window|game] [--window OBS_WINDOW]
|
||||
//! [--restore-token TOKEN] [--codec h264|hevc|av1] [--encoder ID]
|
||||
//! [--seconds N] [--segment-seconds N] [--keyint N] [--bitrate KBPS]
|
||||
//! [--width W --height H --fps F] [--no-audio] [--mic] [--out DIR]
|
||||
//! [--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]
|
||||
//!
|
||||
//! `check` inspects the segments `record` wrote, using the ffprobe and ffmpeg
|
||||
//! command-line tools (install FFmpeg; on Windows, e.g. `winget install ffmpeg`).
|
||||
|
||||
use std::{
|
||||
env,
|
||||
path::PathBuf,
|
||||
process::ExitCode,
|
||||
path::{Path, PathBuf},
|
||||
process::{Command, ExitCode},
|
||||
sync::mpsc::RecvTimeoutError,
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
@@ -38,6 +43,7 @@ fn main() -> ExitCode {
|
||||
let result = match command.as_str() {
|
||||
"encoders" => encoders(&options),
|
||||
"record" => record(&options),
|
||||
"check" => check(&options),
|
||||
_ => return usage("expected a command"),
|
||||
};
|
||||
match result {
|
||||
@@ -51,7 +57,7 @@ fn main() -> ExitCode {
|
||||
}
|
||||
|
||||
fn usage(error: &str) -> ExitCode {
|
||||
eprintln!("{error}\nusage: clipbin-probe encoders|record [options] (see src/main.rs)");
|
||||
eprintln!("{error}\nusage: clipbin-probe encoders|record|check [options] (see src/main.rs)");
|
||||
ExitCode::from(2)
|
||||
}
|
||||
|
||||
@@ -69,6 +75,7 @@ struct Options {
|
||||
format: VideoFormat,
|
||||
audio: bool,
|
||||
mic: bool,
|
||||
min_free_mb: u64,
|
||||
out: PathBuf,
|
||||
}
|
||||
|
||||
@@ -92,6 +99,7 @@ impl Options {
|
||||
},
|
||||
audio: true,
|
||||
mic: false,
|
||||
min_free_mb: StatusTracker::DEFAULT_MIN_FREE_BYTES / 1_000_000,
|
||||
out: env::temp_dir().join("clipbin-probe"),
|
||||
};
|
||||
let mut args = args.iter();
|
||||
@@ -118,6 +126,7 @@ impl Options {
|
||||
"--fps" => options.format.frames_per_second = number(value()?)? as u32,
|
||||
"--no-audio" => options.audio = false,
|
||||
"--mic" => options.mic = true,
|
||||
"--min-free-mb" => options.min_free_mb = number(value()?)? as u64,
|
||||
"--out" => options.out = value()?.into(),
|
||||
_ => return Err(format!("unknown option {flag}")),
|
||||
}
|
||||
@@ -309,7 +318,7 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let started = Instant::now();
|
||||
let elapsed = || format!("{:.3}", started.elapsed().as_secs_f64());
|
||||
let recording = engine.start_recording(config)?;
|
||||
let mut tracker = StatusTracker::new(started);
|
||||
let mut tracker = StatusTracker::new(started, options.min_free_mb * 1_000_000);
|
||||
let mut status = RecordingStatus::Starting;
|
||||
println!("t={} status={}", elapsed(), status.label());
|
||||
|
||||
@@ -323,10 +332,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{} active={} frames={} dropped={} lagged={} muxer_bytes={} segment_bytes={}",
|
||||
"t={} health source={}x{} streaming={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} segment_bytes={}",
|
||||
elapsed(),
|
||||
health.source_width,
|
||||
health.source_height,
|
||||
health.source_streaming,
|
||||
health.output_active,
|
||||
health.total_frames,
|
||||
health.dropped_frames,
|
||||
@@ -368,7 +378,7 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
if let Some(token) = recording.restore_token() {
|
||||
println!("restore_token={token}");
|
||||
}
|
||||
Ok(outcome == Some(StopReason::Success))
|
||||
Ok(outcome == Some(StopReason::Success) && !matches!(status, RecordingStatus::Failed { .. }))
|
||||
}
|
||||
|
||||
fn report_status(shown: &mut RecordingStatus, current: &RecordingStatus, elapsed: &str) {
|
||||
@@ -382,3 +392,128 @@ fn report_status(shown: &mut RecordingStatus, current: &RecordingStatus, elapsed
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Checks the recorded segments: each starts with a keyframe in the chosen
|
||||
/// codec and lasts about one segment length, and consecutive segments remux
|
||||
/// into one MP4 across their boundaries without re-encoding and decode cleanly.
|
||||
fn check(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
|
||||
let dir = options.out.join("session");
|
||||
let mut segments: Vec<PathBuf> = std::fs::read_dir(&dir)?
|
||||
.filter_map(|entry| entry.ok().map(|entry| entry.path()))
|
||||
.filter(|path| {
|
||||
path.extension()
|
||||
.is_some_and(|ext| ext == clipbin_obs::SEGMENT_EXTENSION)
|
||||
})
|
||||
.collect();
|
||||
segments.sort();
|
||||
println!("segments={}", segments.len());
|
||||
if segments.len() < 3 {
|
||||
println!("check=failed need at least three segments");
|
||||
return Ok(false);
|
||||
}
|
||||
|
||||
let expected_codec = codec_key(options.codec);
|
||||
let mut ok = true;
|
||||
let last = segments.len() - 1;
|
||||
for (index, segment) in segments.iter().enumerate() {
|
||||
let name = segment.file_name().unwrap_or_default().to_string_lossy();
|
||||
let codec = ffprobe(
|
||||
segment,
|
||||
&[
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
],
|
||||
)?;
|
||||
let audio_tracks = ffprobe(
|
||||
segment,
|
||||
&["-select_streams", "a", "-show_entries", "stream=index"],
|
||||
)?
|
||||
.lines()
|
||||
.count();
|
||||
let duration: f64 = ffprobe(segment, &["-show_entries", "format=duration"])?
|
||||
.parse()
|
||||
.unwrap_or(0.0);
|
||||
let first_flags = ffprobe(
|
||||
segment,
|
||||
&[
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-read_intervals",
|
||||
"%+#1",
|
||||
"-show_entries",
|
||||
"packet=flags",
|
||||
],
|
||||
)?;
|
||||
let starts_on_keyframe = first_flags.starts_with('K');
|
||||
// The final segment is cut short by the stop request.
|
||||
let full_length = index == last || duration >= options.segment_seconds as f64 * 0.9;
|
||||
let segment_ok =
|
||||
codec == expected_codec && starts_on_keyframe && full_length && audio_tracks > 0;
|
||||
ok &= segment_ok;
|
||||
println!(
|
||||
"segment={name} ok={segment_ok} codec={codec} keyframe_start={starts_on_keyframe} duration={duration:.3} audio_tracks={audio_tracks}"
|
||||
);
|
||||
}
|
||||
|
||||
// A clip spanning the boundaries between the first three segments.
|
||||
let list = options.out.join("concat.txt");
|
||||
let entries: String = segments[..3]
|
||||
.iter()
|
||||
.map(|segment| {
|
||||
format!(
|
||||
"file '{}'\n",
|
||||
segment.to_string_lossy().replace('\'', "'\\''")
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
std::fs::write(&list, entries)?;
|
||||
let clip = options.out.join("cross-boundary-clip.mp4");
|
||||
let remux = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-y", "-f", "concat", "-safe", "0", "-i"])
|
||||
.arg(&list)
|
||||
.args(["-map", "0", "-c", "copy", "-movflags", "+faststart"])
|
||||
.arg(&clip)
|
||||
.status()?;
|
||||
let clip_codec = ffprobe(
|
||||
&clip,
|
||||
&[
|
||||
"-select_streams",
|
||||
"v:0",
|
||||
"-show_entries",
|
||||
"stream=codec_name",
|
||||
],
|
||||
)?;
|
||||
let clip_duration: f64 = ffprobe(&clip, &["-show_entries", "format=duration"])?
|
||||
.parse()
|
||||
.unwrap_or(0.0);
|
||||
let decode = Command::new("ffmpeg")
|
||||
.args(["-v", "error", "-xerror", "-i"])
|
||||
.arg(&clip)
|
||||
.args(["-f", "null", "-"])
|
||||
.output()?;
|
||||
let decode_errors = String::from_utf8_lossy(&decode.stderr).lines().count();
|
||||
let clip_ok = remux.success()
|
||||
&& clip_codec == expected_codec
|
||||
&& decode.status.success()
|
||||
&& decode_errors == 0;
|
||||
ok &= clip_ok;
|
||||
println!(
|
||||
"clip ok={clip_ok} codec={clip_codec} duration={clip_duration:.3} decode_errors={decode_errors} path={}",
|
||||
clip.display()
|
||||
);
|
||||
println!("check={}", if ok { "ok" } else { "failed" });
|
||||
Ok(ok)
|
||||
}
|
||||
|
||||
fn ffprobe(path: &Path, query: &[&str]) -> Result<String, Box<dyn std::error::Error>> {
|
||||
let output = Command::new("ffprobe")
|
||||
.args(["-v", "error"])
|
||||
.args(query)
|
||||
.args(["-of", "default=noprint_wrappers=1:nokey=1"])
|
||||
.arg(path)
|
||||
.output()
|
||||
.map_err(|error| format!("could not run ffprobe (is FFmpeg installed?): {error}"))?;
|
||||
Ok(String::from_utf8_lossy(&output.stdout).trim().to_owned())
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user