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:
@@ -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
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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()),
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user