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,509 @@
|
||||
use std::{
|
||||
fmt,
|
||||
path::{Path, PathBuf},
|
||||
sync::{
|
||||
Arc, Mutex,
|
||||
mpsc::{self, Receiver},
|
||||
},
|
||||
time::{Duration, Instant},
|
||||
};
|
||||
|
||||
use clipbin_core::{RecordingStatus, VideoCodec};
|
||||
|
||||
use crate::{
|
||||
RuntimeLayout,
|
||||
capture::{SettingValue, SourceSpec},
|
||||
encoders::registered_candidates,
|
||||
ffi::{self, Pipeline, PipelineSpec, Session, StartupSpec},
|
||||
runtime::PLUGINS,
|
||||
thread::ObsThread,
|
||||
};
|
||||
|
||||
/// Segments are Matroska: a segment cut short by a crash stays readable, and
|
||||
/// export remuxes to MP4 without re-encoding.
|
||||
pub const SEGMENT_EXTENSION: &str = "mkv";
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct VideoFormat {
|
||||
pub width: u32,
|
||||
pub height: u32,
|
||||
pub frames_per_second: u32,
|
||||
}
|
||||
|
||||
/// The single libobs instance. Dropping it shuts OBS down.
|
||||
pub struct Engine {
|
||||
obs: Arc<ObsThread>,
|
||||
session: Option<Session>,
|
||||
format: VideoFormat,
|
||||
#[cfg(target_os = "linux")]
|
||||
_glib: ffi::GlibLoop,
|
||||
}
|
||||
|
||||
impl Engine {
|
||||
pub fn start(layout: &RuntimeLayout, format: VideoFormat) -> Result<Self, EngineError> {
|
||||
let path = |path: PathBuf| path.to_string_lossy().into_owned();
|
||||
let spec = StartupSpec {
|
||||
libobs_data_dir: path(layout.libobs_data_dir()),
|
||||
plugins: PLUGINS
|
||||
.iter()
|
||||
.map(|plugin| {
|
||||
(
|
||||
path(layout.plugin_library(plugin)),
|
||||
path(layout.plugin_data_dir(plugin)),
|
||||
)
|
||||
})
|
||||
.collect(),
|
||||
width: format.width,
|
||||
height: format.height,
|
||||
frames_per_second: format.frames_per_second,
|
||||
};
|
||||
#[cfg(target_os = "linux")]
|
||||
let glib = ffi::GlibLoop::start();
|
||||
let obs = Arc::new(ObsThread::spawn());
|
||||
let session = obs
|
||||
.run(move || ffi::startup(&spec))
|
||||
.map_err(EngineError::Startup)?;
|
||||
Ok(Self {
|
||||
obs,
|
||||
session: Some(session),
|
||||
format,
|
||||
#[cfg(target_os = "linux")]
|
||||
_glib: glib,
|
||||
})
|
||||
}
|
||||
|
||||
pub fn obs_version(&self) -> &str {
|
||||
&self.session().version
|
||||
}
|
||||
|
||||
/// Bundled plugins that declined to initialise, such as NVENC without an
|
||||
/// NVIDIA GPU. Their encoders are simply not registered.
|
||||
pub fn plugins_failed(&self) -> &[String] {
|
||||
&self.session().plugins_failed
|
||||
}
|
||||
|
||||
fn session(&self) -> &Session {
|
||||
self.session.as_ref().expect("the session lives until drop")
|
||||
}
|
||||
|
||||
/// Registered video encoders as `(id, codec)` pairs, such as `("ffmpeg_vaapi_tex", "h264")`.
|
||||
pub fn registered_video_encoders(&self) -> Vec<(String, String)> {
|
||||
self.obs.run(ffi::registered_video_encoders)
|
||||
}
|
||||
|
||||
/// Registered hardware encoders for `codec`, most preferred first.
|
||||
pub fn hardware_encoders(&self, codec: VideoCodec) -> Vec<String> {
|
||||
let registered: Vec<String> = self
|
||||
.registered_video_encoders()
|
||||
.into_iter()
|
||||
.map(|(id, _)| id)
|
||||
.collect();
|
||||
registered_candidates(codec, ®istered)
|
||||
.into_iter()
|
||||
.map(str::to_owned)
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub fn start_recording(&self, config: RecordingConfig) -> Result<Recording, EngineError> {
|
||||
std::fs::create_dir_all(&config.segment_dir).map_err(EngineError::Io)?;
|
||||
let spec = PipelineSpec {
|
||||
canvas: (self.format.width, self.format.height),
|
||||
video_source: config.video_source,
|
||||
system_audio: config.system_audio,
|
||||
microphone: config.microphone,
|
||||
video_encoder: config.video_encoder,
|
||||
video_encoder_settings: vec![
|
||||
("rate_control", SettingValue::String("CBR".into())),
|
||||
("bitrate", SettingValue::Int(config.video_bitrate_kbps)),
|
||||
(
|
||||
"keyint_sec",
|
||||
SettingValue::Int(config.keyframe_interval_seconds),
|
||||
),
|
||||
],
|
||||
audio_bitrate_kbps: 160,
|
||||
segment_dir: forward_slashes(&config.segment_dir),
|
||||
segment_extension: SEGMENT_EXTENSION,
|
||||
segment_seconds: config.segment_seconds,
|
||||
};
|
||||
let (sender, events) = mpsc::channel();
|
||||
let current_segment = Arc::new(Mutex::new(None));
|
||||
let segment = Arc::clone(¤t_segment);
|
||||
let pipeline = self
|
||||
.obs
|
||||
.run(move || Pipeline::build(&spec, sender, segment).map(Arc::new))
|
||||
.map_err(EngineError::Pipeline)?;
|
||||
let recording = Recording {
|
||||
obs: Arc::clone(&self.obs),
|
||||
pipeline: Some(pipeline),
|
||||
events,
|
||||
current_segment,
|
||||
};
|
||||
recording
|
||||
.with_pipeline(|pipeline| pipeline.start())
|
||||
.map_err(EngineError::Start)?;
|
||||
Ok(recording)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Engine {
|
||||
fn drop(&mut self) {
|
||||
if let Some(session) = self.session.take() {
|
||||
self.obs.run(move || ffi::shutdown(session));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Paths in OBS settings use forward slashes on every platform.
|
||||
fn forward_slashes(path: &Path) -> String {
|
||||
path.to_string_lossy().replace('\\', "/")
|
||||
}
|
||||
|
||||
pub struct RecordingConfig {
|
||||
pub video_source: Option<SourceSpec>,
|
||||
pub system_audio: Option<SourceSpec>,
|
||||
pub microphone: Option<SourceSpec>,
|
||||
pub video_encoder: String,
|
||||
pub video_bitrate_kbps: i64,
|
||||
pub keyframe_interval_seconds: i64,
|
||||
/// Segments split at the first keyframe after this many seconds.
|
||||
pub segment_seconds: i64,
|
||||
pub segment_dir: PathBuf,
|
||||
}
|
||||
|
||||
/// An active recording. Dropping it stops the output and releases its OBS
|
||||
/// objects on the OBS thread. Drop recordings before their `Engine`.
|
||||
pub struct Recording {
|
||||
obs: Arc<ObsThread>,
|
||||
pipeline: Option<Arc<Pipeline>>,
|
||||
events: Receiver<RecorderEvent>,
|
||||
current_segment: Arc<Mutex<Option<String>>>,
|
||||
}
|
||||
|
||||
impl Recording {
|
||||
fn with_pipeline<T: Send + 'static>(
|
||||
&self,
|
||||
operation: impl FnOnce(&Pipeline) -> T + Send + 'static,
|
||||
) -> T {
|
||||
let pipeline = Arc::clone(
|
||||
self.pipeline
|
||||
.as_ref()
|
||||
.expect("the pipeline lives until drop"),
|
||||
);
|
||||
self.obs.run(move || operation(&pipeline))
|
||||
}
|
||||
|
||||
pub fn events(&self) -> &Receiver<RecorderEvent> {
|
||||
&self.events
|
||||
}
|
||||
|
||||
/// OBS's counters plus the size of the segment being written. OBS counts
|
||||
/// bytes handed to its muxer process, which keeps rising even if that
|
||||
/// process cannot write, so only growth on disk proves footage is saved.
|
||||
pub fn health(&self) -> Health {
|
||||
let mut health = self.with_pipeline(Pipeline::health);
|
||||
let segment = self
|
||||
.current_segment
|
||||
.lock()
|
||||
.ok()
|
||||
.and_then(|current| current.clone());
|
||||
if let Some(path) = segment {
|
||||
health.current_segment_bytes =
|
||||
std::fs::metadata(&path).map_or(0, |metadata| metadata.len());
|
||||
health.current_segment = Some(path);
|
||||
}
|
||||
health
|
||||
}
|
||||
|
||||
pub fn restore_token(&self) -> Option<String> {
|
||||
self.with_pipeline(Pipeline::restore_token)
|
||||
}
|
||||
|
||||
/// Requests a stop; `OutputStopped` follows once the last segment is finalised.
|
||||
pub fn stop(&self) {
|
||||
self.with_pipeline(Pipeline::stop)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for Recording {
|
||||
fn drop(&mut self) {
|
||||
if let Some(pipeline) = self.pipeline.take() {
|
||||
// OBS objects must be released on the OBS thread.
|
||||
self.obs.run(move || drop(pipeline));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum RecorderEvent {
|
||||
/// OBS reports the output as started. Not yet proof that footage is written.
|
||||
OutputStarted,
|
||||
/// The muxer opened a new segment file; the previous one is complete.
|
||||
SegmentStarted { path: String },
|
||||
OutputStopped {
|
||||
code: i64,
|
||||
last_error: Option<String>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Counters for honest recording status: OBS accepting a start request is not
|
||||
/// enough, frames must reach disk and the source must have a picture.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct Health {
|
||||
pub source_width: u32,
|
||||
pub source_height: u32,
|
||||
pub output_active: bool,
|
||||
pub total_frames: u64,
|
||||
pub dropped_frames: u64,
|
||||
pub total_bytes: u64,
|
||||
pub lagged_frames: u64,
|
||||
pub current_segment: Option<String>,
|
||||
pub current_segment_bytes: u64,
|
||||
}
|
||||
|
||||
/// Derives the status shown to the user from health samples and output events.
|
||||
///
|
||||
/// Recording is reported only while the source has a picture and encoded data
|
||||
/// keeps reaching the muxer. A stall after recording began is a failure, not a
|
||||
/// silent pause.
|
||||
#[derive(Debug)]
|
||||
pub struct StatusTracker {
|
||||
status: RecordingStatus,
|
||||
last_written: (Option<String>, u64),
|
||||
last_progress: Instant,
|
||||
}
|
||||
|
||||
impl StatusTracker {
|
||||
/// How long footage may stop arriving before recording counts as failed.
|
||||
pub const STALL_LIMIT: Duration = Duration::from_secs(3);
|
||||
|
||||
pub fn new(now: Instant) -> Self {
|
||||
Self {
|
||||
status: RecordingStatus::Starting,
|
||||
last_written: (None, 0),
|
||||
last_progress: now,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn status(&self) -> &RecordingStatus {
|
||||
&self.status
|
||||
}
|
||||
|
||||
pub fn sample(&mut self, health: &Health, now: Instant) -> &RecordingStatus {
|
||||
if matches!(self.status, RecordingStatus::Failed { .. }) {
|
||||
return &self.status;
|
||||
}
|
||||
// Progress means the segment on disk grew or a new one began.
|
||||
let written = (health.current_segment.clone(), health.current_segment_bytes);
|
||||
let progressed = written.1 > 0 && written != self.last_written;
|
||||
if progressed {
|
||||
self.last_written = written;
|
||||
self.last_progress = now;
|
||||
}
|
||||
let has_picture = health.source_width > 0 && health.source_height > 0;
|
||||
let stalled = now.duration_since(self.last_progress) >= Self::STALL_LIMIT;
|
||||
|
||||
self.status = match (&self.status, has_picture, progressed || !stalled) {
|
||||
(_, true, true)
|
||||
if health.output_active && health.total_frames > 0 && self.last_written.1 > 0 =>
|
||||
{
|
||||
RecordingStatus::Recording
|
||||
}
|
||||
(RecordingStatus::Recording, false, _) => RecordingStatus::Failed {
|
||||
reason: "the capture source stopped providing a picture".into(),
|
||||
},
|
||||
(RecordingStatus::Recording, true, false) => RecordingStatus::Failed {
|
||||
reason: format!(
|
||||
"no footage was written for {} seconds",
|
||||
Self::STALL_LIMIT.as_secs()
|
||||
),
|
||||
},
|
||||
(_, false, _) => RecordingStatus::WaitingForCapture,
|
||||
(status, _, _) => status.clone(),
|
||||
};
|
||||
&self.status
|
||||
}
|
||||
|
||||
pub fn stopped(&mut self, reason: StopReason, last_error: Option<&str>) -> &RecordingStatus {
|
||||
self.status = match reason {
|
||||
StopReason::Success => RecordingStatus::Idle,
|
||||
reason => RecordingStatus::Failed {
|
||||
reason: match last_error {
|
||||
Some(error) => format!("{reason:?}: {error}"),
|
||||
None => format!("{reason:?}"),
|
||||
},
|
||||
},
|
||||
};
|
||||
&self.status
|
||||
}
|
||||
}
|
||||
|
||||
/// Why an output stopped, from `obs-output.h`.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum StopReason {
|
||||
Success,
|
||||
BadPath,
|
||||
Unsupported,
|
||||
NoSpace,
|
||||
EncodeError,
|
||||
Disconnected,
|
||||
Error,
|
||||
Other(i64),
|
||||
}
|
||||
|
||||
impl StopReason {
|
||||
pub fn from_code(code: i64) -> Self {
|
||||
match code as i32 {
|
||||
code if code == libobs::OBS_OUTPUT_SUCCESS as i32 => Self::Success,
|
||||
libobs::OBS_OUTPUT_BAD_PATH => Self::BadPath,
|
||||
libobs::OBS_OUTPUT_UNSUPPORTED => Self::Unsupported,
|
||||
libobs::OBS_OUTPUT_NO_SPACE => Self::NoSpace,
|
||||
libobs::OBS_OUTPUT_ENCODE_ERROR => Self::EncodeError,
|
||||
libobs::OBS_OUTPUT_DISCONNECTED => Self::Disconnected,
|
||||
libobs::OBS_OUTPUT_ERROR => Self::Error,
|
||||
_ => Self::Other(code),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum EngineError {
|
||||
Startup(String),
|
||||
Io(std::io::Error),
|
||||
Pipeline(String),
|
||||
Start(String),
|
||||
}
|
||||
|
||||
impl fmt::Display for EngineError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Startup(error) => write!(formatter, "OBS did not start: {error}"),
|
||||
Self::Io(error) => write!(formatter, "{error}"),
|
||||
Self::Pipeline(error) => write!(formatter, "could not set up recording: {error}"),
|
||||
Self::Start(error) => write!(formatter, "recording did not start: {error}"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for EngineError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn healthy(bytes: u64) -> Health {
|
||||
Health {
|
||||
source_width: 1920,
|
||||
source_height: 1080,
|
||||
output_active: true,
|
||||
total_frames: bytes / 1000,
|
||||
dropped_frames: 0,
|
||||
total_bytes: bytes,
|
||||
lagged_frames: 0,
|
||||
current_segment: Some("segment-000000.mkv".into()),
|
||||
current_segment_bytes: bytes,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn footage_must_reach_disk_not_just_the_muxer() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start);
|
||||
let unwritten = Health {
|
||||
current_segment_bytes: 0,
|
||||
..healthy(5000)
|
||||
};
|
||||
assert_ne!(
|
||||
tracker.sample(&unwritten, start),
|
||||
&RecordingStatus::Recording
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.sample(&healthy(5000), start),
|
||||
&RecordingStatus::Recording
|
||||
);
|
||||
// OBS keeps counting, but the file on disk stopped growing.
|
||||
let stuck = Health {
|
||||
total_bytes: 90_000,
|
||||
..healthy(5000)
|
||||
};
|
||||
let later = start + StatusTracker::STALL_LIMIT;
|
||||
assert!(matches!(
|
||||
tracker.sample(&stuck, later),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recording_needs_a_picture_and_written_footage() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start);
|
||||
let no_picture = Health {
|
||||
source_width: 0,
|
||||
source_height: 0,
|
||||
..healthy(5000)
|
||||
};
|
||||
assert_eq!(
|
||||
tracker.sample(&no_picture, start),
|
||||
&RecordingStatus::WaitingForCapture
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.sample(&healthy(0), start),
|
||||
&RecordingStatus::WaitingForCapture
|
||||
);
|
||||
assert_eq!(
|
||||
tracker.sample(&healthy(5000), start),
|
||||
&RecordingStatus::Recording
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stall_or_lost_source_fails_instead_of_pausing() {
|
||||
let start = Instant::now();
|
||||
let mut tracker = StatusTracker::new(start);
|
||||
tracker.sample(&healthy(5000), start);
|
||||
let later = start + StatusTracker::STALL_LIMIT;
|
||||
assert!(matches!(
|
||||
tracker.sample(&healthy(5000), later),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
|
||||
let mut tracker = StatusTracker::new(start);
|
||||
tracker.sample(&healthy(5000), start);
|
||||
let lost = Health {
|
||||
source_width: 0,
|
||||
..healthy(6000)
|
||||
};
|
||||
assert!(matches!(
|
||||
tracker.sample(&lost, start),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
// Failure persists until a new recording starts.
|
||||
assert!(matches!(
|
||||
tracker.sample(&healthy(9000), start),
|
||||
RecordingStatus::Failed { .. }
|
||||
));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn output_errors_fail_and_clean_stops_go_idle() {
|
||||
let mut tracker = StatusTracker::new(Instant::now());
|
||||
assert!(matches!(
|
||||
tracker.stopped(StopReason::NoSpace, Some("disk full")),
|
||||
RecordingStatus::Failed { reason } if reason.contains("disk full")
|
||||
));
|
||||
let mut tracker = StatusTracker::new(Instant::now());
|
||||
assert_eq!(
|
||||
tracker.stopped(StopReason::Success, None),
|
||||
&RecordingStatus::Idle
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn stop_codes_map_to_reasons() {
|
||||
assert_eq!(StopReason::from_code(0), StopReason::Success);
|
||||
assert_eq!(
|
||||
StopReason::from_code(libobs::OBS_OUTPUT_NO_SPACE.into()),
|
||||
StopReason::NoSpace
|
||||
);
|
||||
assert_eq!(StopReason::from_code(-99), StopReason::Other(-99));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user