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.
592 lines
21 KiB
Rust
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())
|
|
}
|