clipbin-obs starts the pinned libobs on a dedicated thread, opens exactly the bundled plugin allowlist, and records official capture sources through one hardware encoder into keyframe-aligned Matroska segments. Status reaches Recording only once the source has a picture and the segment grows on disk. clipbin-probe lists and verifies hardware encoders and records from a real capture source, printing status, health and segment events for validation.
385 lines
14 KiB
Rust
385 lines
14 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] [--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<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),
|
|
_ => 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<String>,
|
|
codec: VideoCodec,
|
|
encoder: Option<String>,
|
|
seconds: u64,
|
|
segment_seconds: i64,
|
|
keyint: i64,
|
|
bitrate: i64,
|
|
format: VideoFormat,
|
|
audio: bool,
|
|
mic: bool,
|
|
out: PathBuf,
|
|
}
|
|
|
|
impl Options {
|
|
fn parse(args: &[String]) -> Result<Self, String> {
|
|
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::<i64>()
|
|
.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<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.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<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,
|
|
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())?;
|
|
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<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),
|
|
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()),
|
|
}
|
|
}
|
|
}
|