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,200 @@
|
||||
use std::{
|
||||
env, fmt, io,
|
||||
path::{Path, PathBuf},
|
||||
};
|
||||
|
||||
/// Where the bundled OBS runtime's data and plugins live.
|
||||
///
|
||||
/// Packaged builds keep OBS's own install layout and place the Clipbin
|
||||
/// executable where OBS expects its front end, because OBS finds helper
|
||||
/// executables such as `obs-ffmpeg-mux` next to the running executable:
|
||||
///
|
||||
/// * Linux: `<root>/bin/clipbin`, `<root>/lib/obs-plugins`, `<root>/share/obs`.
|
||||
/// * Windows: `<root>/bin/64bit/clipbin.exe`, `<root>/obs-plugins/64bit`, `<root>/data`.
|
||||
///
|
||||
/// Development builds use `CLIPBIN_OBS_RUNTIME_DIR` (see `.cargo/config.toml`),
|
||||
/// the directory holding `<os>-<arch>` runtimes assembled by `scripts/obs/`.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct RuntimeLayout {
|
||||
root: PathBuf,
|
||||
}
|
||||
|
||||
impl RuntimeLayout {
|
||||
pub fn new(root: impl Into<PathBuf>) -> Self {
|
||||
Self { root: root.into() }
|
||||
}
|
||||
|
||||
/// Finds the runtime for this executable, preferring the development override.
|
||||
pub fn discover() -> Result<Self, RuntimeError> {
|
||||
// Debug builds also remember the development runtime from build time,
|
||||
// so binaries in target/ work when started outside Cargo.
|
||||
let dev_dir = env::var_os("CLIPBIN_OBS_RUNTIME_DIR")
|
||||
.map(PathBuf::from)
|
||||
.or_else(|| {
|
||||
cfg!(debug_assertions)
|
||||
.then(|| option_env!("CLIPBIN_OBS_RUNTIME_DIR").map(PathBuf::from))?
|
||||
});
|
||||
let layout = match dev_dir {
|
||||
Some(dir) => Self::new(dir.join(platform_dir())),
|
||||
None => {
|
||||
let exe = env::current_exe().map_err(RuntimeError::Executable)?;
|
||||
Self::from_executable(&exe).ok_or_else(|| RuntimeError::Layout(exe.clone()))?
|
||||
}
|
||||
};
|
||||
layout.check()?;
|
||||
Ok(layout)
|
||||
}
|
||||
|
||||
fn from_executable(exe: &Path) -> Option<Self> {
|
||||
let bin_dir = exe.parent()?;
|
||||
let root = if cfg!(windows) {
|
||||
bin_dir.parent()?.parent()?
|
||||
} else {
|
||||
bin_dir.parent()?
|
||||
};
|
||||
Some(Self::new(root))
|
||||
}
|
||||
|
||||
pub fn root(&self) -> &Path {
|
||||
&self.root
|
||||
}
|
||||
|
||||
pub fn libobs_data_dir(&self) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
self.root.join("data").join("libobs")
|
||||
} else {
|
||||
self.root.join("share").join("obs").join("libobs")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin_bin_dir(&self) -> PathBuf {
|
||||
if cfg!(windows) {
|
||||
self.root.join("obs-plugins").join("64bit")
|
||||
} else {
|
||||
self.root.join("lib").join("obs-plugins")
|
||||
}
|
||||
}
|
||||
|
||||
pub fn plugin_data_dir(&self, plugin: &str) -> PathBuf {
|
||||
let base = if cfg!(windows) {
|
||||
self.root.join("data").join("obs-plugins")
|
||||
} else {
|
||||
self.root.join("share").join("obs").join("obs-plugins")
|
||||
};
|
||||
base.join(plugin)
|
||||
}
|
||||
|
||||
pub fn plugin_library(&self, plugin: &str) -> PathBuf {
|
||||
let extension = if cfg!(windows) { "dll" } else { "so" };
|
||||
self.plugin_bin_dir().join(format!("{plugin}.{extension}"))
|
||||
}
|
||||
|
||||
/// The OBS version recorded when the runtime was assembled.
|
||||
pub fn version(&self) -> Option<String> {
|
||||
let manifest = std::fs::read_to_string(self.root.join("VERSION")).ok()?;
|
||||
manifest
|
||||
.lines()
|
||||
.find_map(|line| line.strip_prefix("obs="))
|
||||
.map(str::to_owned)
|
||||
}
|
||||
|
||||
fn check(&self) -> Result<(), RuntimeError> {
|
||||
if !self.libobs_data_dir().is_dir() {
|
||||
return Err(RuntimeError::Missing(self.libobs_data_dir()));
|
||||
}
|
||||
for plugin in PLUGINS {
|
||||
if !self.plugin_library(plugin).is_file() {
|
||||
return Err(RuntimeError::Missing(self.plugin_library(plugin)));
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
/// The official OBS plugins Clipbin loads, and the only ones it ships.
|
||||
/// Keep in sync with the allowlists in scripts/obs/.
|
||||
#[cfg(windows)]
|
||||
pub const PLUGINS: &[&str] = &[
|
||||
"win-capture",
|
||||
"win-wasapi",
|
||||
"obs-ffmpeg",
|
||||
"obs-nvenc",
|
||||
"obs-qsv11",
|
||||
];
|
||||
#[cfg(not(windows))]
|
||||
pub const PLUGINS: &[&str] = &[
|
||||
"linux-capture",
|
||||
"linux-pipewire",
|
||||
"linux-pulseaudio",
|
||||
"obs-ffmpeg",
|
||||
"obs-nvenc",
|
||||
];
|
||||
|
||||
/// The runtime directory name scripts/obs/ assembles for this platform, such as
|
||||
/// `linux-x86_64` or `windows-x86_64`.
|
||||
pub fn platform_dir() -> String {
|
||||
format!("{}-{}", env::consts::OS, env::consts::ARCH)
|
||||
}
|
||||
|
||||
#[derive(Debug)]
|
||||
pub enum RuntimeError {
|
||||
Executable(io::Error),
|
||||
Layout(PathBuf),
|
||||
Missing(PathBuf),
|
||||
}
|
||||
|
||||
impl fmt::Display for RuntimeError {
|
||||
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
|
||||
match self {
|
||||
Self::Executable(error) => write!(formatter, "cannot locate the executable: {error}"),
|
||||
Self::Layout(exe) => write!(
|
||||
formatter,
|
||||
"{} is not inside an OBS runtime layout",
|
||||
exe.display()
|
||||
),
|
||||
Self::Missing(dir) => write!(
|
||||
formatter,
|
||||
"the OBS runtime is incomplete: {} is missing (run scripts/obs/ to assemble it)",
|
||||
dir.display()
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl std::error::Error for RuntimeError {}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn packaged_layout_is_relative_to_the_executable() {
|
||||
let (exe, root) = if cfg!(windows) {
|
||||
(r"C:\Clipbin\bin\64bit\clipbin.exe", r"C:\Clipbin")
|
||||
} else {
|
||||
("/opt/clipbin/bin/clipbin", "/opt/clipbin")
|
||||
};
|
||||
let layout = RuntimeLayout::from_executable(Path::new(exe)).unwrap();
|
||||
assert_eq!(layout.root(), Path::new(root));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn plugin_paths_follow_the_obs_install_layout() {
|
||||
let layout = RuntimeLayout::new("rt");
|
||||
if cfg!(windows) {
|
||||
assert_eq!(layout.plugin_bin_dir(), Path::new(r"rt\obs-plugins\64bit"));
|
||||
assert_eq!(layout.libobs_data_dir(), Path::new(r"rt\data\libobs"));
|
||||
} else {
|
||||
assert_eq!(layout.plugin_bin_dir(), Path::new("rt/lib/obs-plugins"));
|
||||
assert_eq!(layout.libobs_data_dir(), Path::new("rt/share/obs/libobs"));
|
||||
assert_eq!(
|
||||
layout.plugin_library("obs-ffmpeg"),
|
||||
Path::new("rt/lib/obs-plugins/obs-ffmpeg.so")
|
||||
);
|
||||
assert_eq!(
|
||||
layout.plugin_data_dir("obs-ffmpeg"),
|
||||
Path::new("rt/share/obs/obs-plugins/obs-ffmpeg")
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user