feat: add OBS recording engine and validation probe

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.
This commit is contained in:
2026-10-08 14:12:06 +02:00
parent 80b6abdb32
commit f2eb7139da
14 changed files with 2470 additions and 3 deletions
+16
View File
@@ -0,0 +1,16 @@
[package]
name = "clipbin-probe"
description = "Validation probe for Clipbin's OBS capture, encoder and segment pipeline."
version.workspace = true
edition.workspace = true
rust-version.workspace = true
license.workspace = true
repository.workspace = true
publish.workspace = true
[dependencies]
clipbin-core.workspace = true
clipbin-obs.workspace = true
[lints]
workspace = true
+56
View File
@@ -0,0 +1,56 @@
//! Makes development builds run against the OBS runtime in target/obs-runtime.
//!
//! OBS starts `obs-ffmpeg-mux` from the running executable's directory, and on
//! Windows the OBS DLLs must sit beside the executable too, so they are linked
//! (Unix) or copied (Windows) into the Cargo profile directory. A symlink keeps
//! the helper's `$ORIGIN/../lib` runpath pointing into the runtime. Packaged
//! builds place the executable in the runtime's own bin directory instead (see
//! clipbin_obs::RuntimeLayout).
use std::{env, fs, path::PathBuf};
fn main() {
println!("cargo:rerun-if-env-changed=CLIPBIN_OBS_RUNTIME_DIR");
let target_os = env::var("CARGO_CFG_TARGET_OS").unwrap();
let arch = env::var("CARGO_CFG_TARGET_ARCH").unwrap();
if target_os == "linux" {
// Packaged layout: <root>/bin/<exe> loads <root>/lib/libobs.so.
println!("cargo:rustc-link-arg-bins=-Wl,-rpath,$ORIGIN/../lib");
}
let Some(runtime_dir) = env::var_os("CLIPBIN_OBS_RUNTIME_DIR") else {
return;
};
let runtime = PathBuf::from(runtime_dir).join(format!("{target_os}-{arch}"));
// OUT_DIR is <profile>/build/<package>-<hash>/out.
let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap());
let profile_dir = out_dir.ancestors().nth(3).unwrap().to_path_buf();
let (bin_dir, lib_dir) = match target_os.as_str() {
"windows" => (runtime.join("bin").join("64bit"), None),
_ => (runtime.join("bin"), Some(runtime.join("lib"))),
};
if let Some(lib_dir) = lib_dir {
println!("cargo:rustc-link-arg-bins=-Wl,-rpath,{}", lib_dir.display());
}
let Ok(entries) = fs::read_dir(&bin_dir) else {
println!(
"cargo:warning=OBS runtime not found at {}; run scripts/obs/ to assemble it",
runtime.display()
);
return;
};
println!("cargo:rerun-if-changed={}", bin_dir.display());
for entry in entries.flatten() {
let path = entry.path();
if !path.is_file() {
continue;
}
let destination = profile_dir.join(entry.file_name());
let _ = fs::remove_file(&destination);
#[cfg(unix)]
std::os::unix::fs::symlink(&path, &destination).unwrap();
#[cfg(not(unix))]
fs::copy(&path, &destination).unwrap();
}
}
+384
View File
@@ -0,0 +1,384 @@
//! 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()),
}
}
}