//! Validates the recording foundation on real hardware before the UI is built: //! the pinned OBS runtime, hardware encoders, official capture sources, //! keyframe-aligned segments and honest status. Prints `key=value` lines that //! scripts/validate/ checks. //! //! clipbin-probe encoders [--verify] [--out DIR] //! 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] //! [--min-free-mb MB] [--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`). use std::{ env, path::{Path, PathBuf}, process::{Command, ExitCode}, sync::mpsc::RecvTimeoutError, time::{Duration, Instant}, }; use clipbin_core::{RecordingStatus, VideoCodec}; use clipbin_obs::{ Engine, RecorderEvent, RecordingConfig, RuntimeLayout, StatusTracker, StopReason, capture::{self, CaptureTarget, MonitorMatch, WindowMatch}, encoders::ALL_CODECS, }; fn main() -> ExitCode { let mut args: Vec = env::args().skip(1).collect(); let command = if args.is_empty() { String::new() } else { args.remove(0) }; let options = match Options::parse(&args) { Ok(options) => options, Err(error) => return usage(&error), }; let result = match command.as_str() { "encoders" => encoders(&options), "record" => record(&options), "check" => check(&options), _ => return usage("expected a command"), }; match result { Ok(true) => ExitCode::SUCCESS, Ok(false) => ExitCode::FAILURE, Err(error) => { println!("error={error}"); ExitCode::FAILURE } } } fn usage(error: &str) -> ExitCode { eprintln!("{error}\nusage: clipbin-probe encoders|record|check [options] (see src/main.rs)"); ExitCode::from(2) } struct Options { verify: bool, expect_motion: bool, target: String, window: String, restore_token: Option, codec: VideoCodec, encoder: Option, seconds: u64, segment_seconds: i64, keyint: i64, bitrate: i64, /// The recording size; by default it follows the captured source. width: Option, height: Option, frames_per_second: u32, audio: bool, mic: bool, min_free_mb: u64, out: PathBuf, } impl Options { fn parse(args: &[String]) -> Result { let mut options = Self { verify: false, expect_motion: false, target: "monitor".into(), window: String::new(), restore_token: None, codec: VideoCodec::H264, encoder: None, seconds: 20, segment_seconds: 2, keyint: 1, bitrate: 20_000, width: None, height: None, frames_per_second: 60, 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(); while let Some(flag) = args.next() { let mut value = || args.next().cloned().ok_or(format!("{flag} needs a value")); let number = |value: String| { value .parse::() .map_err(|_| format!("{flag}: not a number")) }; 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()?), "--codec" => options.codec = parse_codec(&value()?)?, "--encoder" => options.encoder = Some(value()?), "--seconds" => options.seconds = number(value()?)? as u64, "--segment-seconds" => options.segment_seconds = number(value()?)?, "--keyint" => options.keyint = number(value()?)?, "--bitrate" => options.bitrate = number(value()?)?, "--width" => options.width = Some(number(value()?)? as u32), "--height" => options.height = Some(number(value()?)? as u32), "--fps" => options.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}")), } } Ok(options) } fn resolution(&self) -> Result, String> { match (self.width, self.height) { (Some(width), Some(height)) => Ok(Some((width, height))), (None, None) => Ok(None), _ => Err("--width and --height go together".into()), } } fn capture_target(&self) -> Result { let window = WindowMatch { obs_window: self.window.clone(), restore_token: self.restore_token.clone(), }; match self.target.as_str() { "game" => Ok(CaptureTarget::Game { window }), "window" => Ok(CaptureTarget::Window { window }), "monitor" => Ok(CaptureTarget::Monitor { monitor: MonitorMatch { obs_monitor: self.window.clone(), restore_token: self.restore_token.clone(), }, }), other => Err(format!("unknown target {other}")), } } } fn parse_codec(value: &str) -> Result { match value { "h264" => Ok(VideoCodec::H264), "hevc" => Ok(VideoCodec::Hevc), "av1" => Ok(VideoCodec::Av1), other => Err(format!("unknown codec {other}")), } } fn codec_key(codec: VideoCodec) -> &'static str { match codec { VideoCodec::H264 => "h264", VideoCodec::Hevc => "hevc", VideoCodec::Av1 => "av1", } } fn start_engine(options: &Options) -> Result> { let layout = RuntimeLayout::discover()?; println!("runtime={}", layout.root().display()); println!( "runtime_obs_version={}", layout.version().unwrap_or_default() ); let engine = Engine::start(&layout, options.frames_per_second)?; println!("obs_version={}", engine.obs_version()); for plugin in engine.plugins_failed() { println!("plugin_not_initialised={plugin}"); } println!("platform={}", clipbin_obs::runtime::platform_dir()); #[cfg(not(windows))] println!("session={:?}", capture::LinuxSession::detect()); Ok(engine) } /// Lists hardware encoders per codec. With `--verify`, proves each codec by /// recording a few seconds with its preferred encoder and checking frames land /// on disk: a registered encoder can still fail on the actual device. fn encoders(options: &Options) -> Result> { let engine = start_engine(options)?; for (id, codec) in engine.registered_video_encoders() { println!("registered_encoder={id} codec={codec}"); } let mut all_ok = true; for codec in ALL_CODECS { let key = codec_key(codec); let candidates = engine.hardware_encoders(codec); println!("candidates.{key}={}", candidates.join(",")); if !options.verify { continue; } let mut usable = None; for encoder in &candidates { let dir = options.out.join(format!("verify-{key}-{encoder}")); let _ = std::fs::remove_dir_all(&dir); let config = RecordingConfig { video_source: None, system_audio: None, microphone: None, resolution: Some(options.resolution()?.unwrap_or((1920, 1080))), video_encoder: encoder.clone(), video_bitrate_kbps: options.bitrate, keyframe_interval_seconds: options.keyint, segment_seconds: options.segment_seconds, segment_dir: dir, }; match verify_encoder(&engine, config) { Ok(frames) => { println!("verify.{key}.{encoder}=ok frames={frames}"); usable = Some(encoder.clone()); break; } Err(error) => println!("verify.{key}.{encoder}=failed {error}"), } } match usable { Some(encoder) => println!("usable.{key}={encoder}"), None => { println!("usable.{key}=none"); all_ok &= codec != VideoCodec::H264; } } } Ok(all_ok) } fn verify_encoder(engine: &Engine, config: RecordingConfig) -> Result { let recording = engine .start_recording(config) .map_err(|error| error.to_string())?; recording.poll().map_err(|error| error.to_string())?; std::thread::sleep(Duration::from_secs(3)); let health = recording.poll().map_err(|error| error.to_string())?; recording.stop(); let stop = wait_for_stop(&recording, Duration::from_secs(10)); match stop { Some((StopReason::Success, _)) if health.total_frames > 0 && health.total_bytes > 0 => { Ok(health.total_frames) } Some((reason, error)) => Err(format!( "stopped={reason:?} frames={} bytes={} error={}", health.total_frames, health.total_bytes, error.unwrap_or_default() )), None => Err("the output did not stop".into()), } } fn wait_for_stop( recording: &clipbin_obs::Recording, timeout: Duration, ) -> Option<(StopReason, Option)> { let deadline = Instant::now() + timeout; while let Some(remaining) = deadline.checked_duration_since(Instant::now()) { match recording.events().recv_timeout(remaining) { Ok(RecorderEvent::OutputStopped { code, last_error }) => { return Some((StopReason::from_code(code), last_error)); } Ok(_) => {} Err(_) => break, } } None } /// Records from a real capture source, printing every status change and /// segment so scripts/validate/ can check timing, keyframes and continuity. fn record(options: &Options) -> Result> { let engine = start_engine(options)?; let target = options.capture_target()?; #[cfg(windows)] let video_source = target.video_source(); #[cfg(not(windows))] let video_source = target.video_source( capture::LinuxSession::detect() .ok_or("no Wayland or X11 session (WAYLAND_DISPLAY/DISPLAY unset)")?, ); println!("video_source={}", video_source.id); let encoder = match &options.encoder { Some(encoder) => encoder.clone(), None => engine .hardware_encoders(options.codec) .into_iter() .next() .ok_or_else(|| format!("no hardware {} encoder is registered", options.codec))?, }; println!("encoder={encoder}"); let dir = options.out.join("session"); let _ = std::fs::remove_dir_all(&dir); println!("segment_dir={}", dir.display()); let config = RecordingConfig { video_source: Some(video_source), system_audio: options.audio.then(capture::system_audio_source), microphone: options.mic.then(capture::microphone_source), resolution: options.resolution()?, video_encoder: encoder, video_bitrate_kbps: options.bitrate, keyframe_interval_seconds: options.keyint, segment_seconds: options.segment_seconds, segment_dir: dir, }; 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, options.min_free_mb * 1_000_000, Duration::from_secs(options.segment_seconds as u64), ); let mut status = RecordingStatus::Starting; let mut recorded = false; println!("t={} status={}", elapsed(), status.label()); let stop_at = started + Duration::from_secs(options.seconds); let mut next_sample = started; let mut stopping = false; let mut outcome = None; while outcome.is_none() { let now = Instant::now(); if now >= next_sample { next_sample += Duration::from_secs(1); let health = recording.poll()?; println!( "t={} health source={}x{} pipewire={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}", elapsed(), health.source_width, health.source_height, health.pipewire, health.output_active, health.total_frames, health.dropped_frames, health.lagged_frames, health.total_bytes, health.written_bytes ); report_status(&mut status, tracker.sample(&health, now), &elapsed()); recorded |= status == RecordingStatus::Recording; } if !stopping && (now >= stop_at || matches!(status, RecordingStatus::Failed { .. })) { stopping = true; println!("t={} stop_requested", elapsed()); recording.stop(); } match recording.events().recv_timeout(Duration::from_millis(100)) { Ok(RecorderEvent::OutputSized { width, height }) => { println!("t={} recording_size={width}x{height}", elapsed()) } Ok(RecorderEvent::OutputStarted) => println!("t={} output_started", elapsed()), Ok(RecorderEvent::SegmentStarted { path }) => { println!("t={} segment_started={path}", elapsed()) } Ok(RecorderEvent::OutputStopped { code, last_error }) => { let reason = StopReason::from_code(code); println!( "t={} output_stopped={reason:?} error={}", elapsed(), last_error.clone().unwrap_or_default() ); report_status( &mut status, tracker.stopped(reason, last_error.as_deref()), &elapsed(), ); outcome = Some(reason); } Err(RecvTimeoutError::Timeout) => {} Err(RecvTimeoutError::Disconnected) => break, } } if let Some(token) = recording.restore_token() { println!("restore_token={token}"); } println!("recorded={recorded}"); Ok(recorded && outcome == Some(StopReason::Success) && !matches!(status, RecordingStatus::Failed { .. })) } fn report_status(shown: &mut RecordingStatus, current: &RecordingStatus, elapsed: &str) { if shown != current { *shown = current.clone(); match current { RecordingStatus::Failed { reason } => { println!("t={elapsed} status=failed reason={reason}") } status => println!("t={elapsed} status={}", status.label()), } } } /// 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> { let dir = options.out.join("session"); let mut segments: Vec = 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 black or frozen capture still produces valid segments. When the // captured content was moving, some segment must show it (capture may // start late, after the user answers the picker). if options.expect_motion { 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={} segment={}", most.0, most.1 .map_or(String::new(), |segment| segment.display().to_string()) ); } // 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) } /// 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"]) .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()) }