//! 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] [--out DIR] use std::{ env, path::PathBuf, process::ExitCode, sync::mpsc::RecvTimeoutError, time::{Duration, Instant}, }; use clipbin_core::{RecordingStatus, VideoCodec}; use clipbin_obs::{ Engine, RecorderEvent, RecordingConfig, RuntimeLayout, StatusTracker, StopReason, VideoFormat, 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), _ => 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 [options] (see src/main.rs)"); ExitCode::from(2) } struct Options { verify: bool, target: String, window: String, restore_token: Option, codec: VideoCodec, encoder: Option, seconds: u64, segment_seconds: i64, keyint: i64, bitrate: i64, format: VideoFormat, audio: bool, mic: bool, out: PathBuf, } impl Options { fn parse(args: &[String]) -> Result { let mut options = Self { verify: 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, format: VideoFormat { width: 1920, height: 1080, frames_per_second: 60, }, audio: true, mic: false, 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, "--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.format.width = number(value()?)? as u32, "--height" => options.format.height = number(value()?)? as u32, "--fps" => options.format.frames_per_second = number(value()?)? as u32, "--no-audio" => options.audio = false, "--mic" => options.mic = true, "--out" => options.out = value()?.into(), _ => return Err(format!("unknown option {flag}")), } } Ok(options) } 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.format)?; 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, 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())?; std::thread::sleep(Duration::from_secs(3)); let health = recording.health(); 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), 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); let mut status = RecordingStatus::Starting; 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.health(); println!( "t={} health source={}x{} active={} frames={} dropped={} lagged={} muxer_bytes={} segment_bytes={}", elapsed(), health.source_width, health.source_height, health.output_active, health.total_frames, health.dropped_frames, health.lagged_frames, health.total_bytes, health.current_segment_bytes ); report_status(&mut status, tracker.sample(&health, now), &elapsed()); } 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::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}"); } Ok(outcome == Some(StopReason::Success)) } 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()), } } }