Files
clipbin-desktop/crates/clipbin-probe/src/main.rs
T
harry 795118c200
Rust checks / windows-cross-check (push) Successful in 41s
Rust checks / linux (push) Failing after 5m37s
feat: record at the source's size, only once capture works
Encoders and the segment output are created when the capture source first
has a picture, at its own size (portrait monitors record portrait) unless a
resolution is configured, so nothing is written while waiting for a source.
SPEC.md records both decisions; validation docs cover how sessions should end
when the game closes.
2026-10-08 15:34:42 +02:00

592 lines
21 KiB
Rust

//! 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<String> = 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<String>,
codec: VideoCodec,
encoder: Option<String>,
seconds: u64,
segment_seconds: i64,
keyint: i64,
bitrate: i64,
/// The recording size; by default it follows the captured source.
width: Option<u32>,
height: Option<u32>,
frames_per_second: u32,
audio: bool,
mic: bool,
min_free_mb: u64,
out: PathBuf,
}
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,
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::<i64>()
.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<Option<(u32, u32)>, 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<CaptureTarget, String> {
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<VideoCodec, String> {
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<Engine, Box<dyn std::error::Error>> {
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<bool, Box<dyn std::error::Error>> {
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<u64, String> {
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<String>)> {
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<bool, Box<dyn std::error::Error>> {
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<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 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<u64, Box<dyn std::error::Error>> {
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<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())
}