feat: record at the source's size, only once capture works
Rust checks / windows-cross-check (push) Successful in 41s
Rust checks / linux (push) Failing after 5m37s

Encoders and the segment output are created when the capture source first
has a picture, at its own size (portrait monitors record portrait) unless a
resolution is configured, so nothing is written while waiting for a source.
SPEC.md records both decisions; validation docs cover how sessions should end
when the game closes.
This commit is contained in:
2026-10-08 15:34:42 +02:00
parent 96590e9c4a
commit 795118c200
9 changed files with 448 additions and 260 deletions
+4 -4
View File
@@ -21,8 +21,9 @@ impl fmt::Display for VideoCodec {
/// In-memory defaults from SPEC.md. Persistence and editing come with the recorder.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings {
pub width: u32,
pub height: u32,
/// A fixed recording size. `None` records at the captured window's or
/// monitor's own size, following its orientation.
pub resolution: Option<(u32, u32)>,
pub frames_per_second: u32,
pub codec: VideoCodec,
pub system_audio_enabled: bool,
@@ -38,8 +39,7 @@ pub struct Settings {
impl Default for Settings {
fn default() -> Self {
Self {
width: 1920,
height: 1080,
resolution: None,
frames_per_second: 60,
codec: VideoCodec::H264,
system_audio_enabled: true,
+7 -2
View File
@@ -78,8 +78,13 @@ impl Render for ClipbinView {
.text_sm()
.text_color(rgb(0xa9b2c2))
.child(format!(
"Recording defaults: {} × {} · {} FPS · Hardware {}",
settings.width, settings.height, settings.frames_per_second, settings.codec,
"Recording defaults: {} · {} FPS · Hardware {}",
match settings.resolution {
Some((width, height)) => format!("{width} × {height}"),
None => "Captured window size".into(),
},
settings.frames_per_second,
settings.codec,
))
.child(format!(
"Session budget: {} GB of unprotected footage · Clip: {} seconds",
+309 -186
View File
@@ -224,28 +224,9 @@ pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
return Err("libobs could not set up audio".into());
}
let graphics_module = cstring(if cfg!(windows) {
"libobs-d3d11"
} else {
"libobs-opengl"
});
let mut video: sys::obs_video_info = std::mem::zeroed();
video.graphics_module = graphics_module.as_ptr();
video.fps_num = spec.frames_per_second;
video.fps_den = 1;
video.base_width = spec.width;
video.base_height = spec.height;
video.output_width = spec.width;
video.output_height = spec.height;
video.output_format = sys::video_format_VIDEO_FORMAT_NV12;
video.gpu_conversion = true;
video.colorspace = sys::video_colorspace_VIDEO_CS_709;
video.range = sys::video_range_type_VIDEO_RANGE_DEFAULT;
video.scale_type = sys::obs_scale_type_OBS_SCALE_BICUBIC;
let code = sys::obs_reset_video(&mut video);
if code != sys::OBS_VIDEO_SUCCESS as i32 {
if let Err(error) = reset_video((spec.width, spec.height), spec.frames_per_second) {
sys::obs_shutdown();
return Err(format!("libobs could not start video (code {code})"));
return Err(error);
}
// Open exactly the bundled plugins. obs_load_all_modules would also
@@ -280,6 +261,39 @@ pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
}
}
/// Sets the canvas and output size. Fails while an output is active.
fn reset_video((width, height): (u32, u32), frames_per_second: u32) -> Result<(), String> {
let graphics_module = cstring(if cfg!(windows) {
"libobs-d3d11"
} else {
"libobs-opengl"
});
// SAFETY: runs on the OBS thread; obs_video_info is plain data.
unsafe {
let mut video: sys::obs_video_info = std::mem::zeroed();
video.graphics_module = graphics_module.as_ptr();
video.fps_num = frames_per_second;
video.fps_den = 1;
video.base_width = width;
video.base_height = height;
video.output_width = width;
video.output_height = height;
video.output_format = sys::video_format_VIDEO_FORMAT_NV12;
video.gpu_conversion = true;
video.colorspace = sys::video_colorspace_VIDEO_CS_709;
video.range = sys::video_range_type_VIDEO_RANGE_DEFAULT;
video.scale_type = sys::obs_scale_type_OBS_SCALE_BICUBIC;
let code = sys::obs_reset_video(&mut video);
if code == sys::OBS_VIDEO_SUCCESS as i32 {
Ok(())
} else {
Err(format!(
"libobs could not set up {width}x{height} video (code {code})"
))
}
}
}
pub(crate) fn shutdown(session: Session) {
// SAFETY: runs on the OBS thread after every OBS object was released.
// Released sources are destroyed on a libobs task thread, and destroying
@@ -445,17 +459,24 @@ pub(crate) fn registered_video_encoders() -> Vec<(String, String)> {
encoders
}
pub(crate) struct PipelineSpec {
pub canvas: (u32, u32),
pub(crate) struct CaptureSpec {
pub video_source: Option<SourceSpec>,
pub system_audio: Option<SourceSpec>,
pub microphone: Option<SourceSpec>,
}
pub(crate) struct OutputSpec {
pub frames_per_second: u32,
/// The recording size; `None` follows the captured source.
pub resolution: Option<(u32, u32)>,
pub video_encoder: String,
pub video_encoder_settings: Vec<(&'static str, SettingValue)>,
pub audio_bitrate_kbps: i64,
pub segment_dir: String,
pub segment_extension: &'static str,
pub segment_seconds: i64,
pub events: Sender<RecorderEvent>,
pub current_segment: Arc<Mutex<Option<String>>>,
}
/// OBS output channels, following the OBS front end's convention.
@@ -468,6 +489,25 @@ pub(crate) const SYSTEM_TRACK: usize = 0;
pub(crate) const MICROPHONE_TRACK: usize = 1;
pub(crate) const COMBINED_TRACK: usize = 2;
/// With the microphone on, system and microphone audio get their own tracks
/// for editing plus a combined track for playback. With only system audio,
/// its single track is already the combined mix. The FFmpeg muxer needs at
/// least one audio track, so recording without audio writes a silent one.
fn audio_tracks(microphone: bool) -> &'static [usize] {
if microphone {
&[SYSTEM_TRACK, MICROPHONE_TRACK, COMBINED_TRACK]
} else {
&[SYSTEM_TRACK]
}
}
/// The recording size for a captured picture: the configured resolution, or
/// the source's own size. NV12 encoding needs even dimensions.
pub(crate) fn canvas_size(resolution: Option<(u32, u32)>, source: (u32, u32)) -> (u32, u32) {
let (width, height) = resolution.unwrap_or(source);
((width & !1).max(2), (height & !1).max(2))
}
pub(crate) fn segment_stem(index: u64) -> String {
format!("segment-{index:06}")
}
@@ -505,7 +545,7 @@ const SIGNALS: [(
];
unsafe extern "C" fn on_start(data: *mut c_void, _: *mut sys::calldata_t) {
// SAFETY: data is the SignalContext registered in Pipeline::build, which
// SAFETY: data is the SignalContext registered in Output::build, which
// outlives the connection.
let context = unsafe { &*(data as *const SignalContext) };
context.send(RecorderEvent::OutputStarted);
@@ -557,16 +597,19 @@ pub(crate) fn segment_index(path: &str) -> Option<u64> {
stem.strip_prefix("segment-")?.parse().ok()
}
/// The live OBS objects for one recording.
/// The live OBS objects for one recording. Capture starts at once; encoders
/// and the segment output are created only when the source has a picture,
/// so nothing is written while the user picks a source or capture is down.
pub(crate) struct Pipeline {
name_prefix: String,
scene: *mut sys::obs_scene_t,
scene_item: *mut sys::obs_sceneitem_t,
/// Each source with the output channel it is bound to, if any.
sources: Vec<(Option<u32>, *mut sys::obs_source_t)>,
video_source: *mut sys::obs_source_t,
video_encoder: *mut sys::obs_encoder_t,
audio_encoders: Vec<*mut sys::obs_encoder_t>,
output: *mut sys::obs_output_t,
signals: Option<Box<SignalContext>>,
microphone: bool,
pending: Mutex<Option<OutputSpec>>,
output: Mutex<Option<Output>>,
}
// SAFETY: the pointers are only dereferenced on the OBS thread (see module docs).
@@ -574,16 +617,12 @@ unsafe impl Send for Pipeline {}
unsafe impl Sync for Pipeline {}
impl Pipeline {
pub(crate) fn build(
spec: &PipelineSpec,
events: Sender<RecorderEvent>,
current_segment: Arc<Mutex<Option<String>>>,
) -> Result<Self, String> {
pub(crate) fn build(capture: &CaptureSpec, output: OutputSpec) -> Result<Self, String> {
PIPEWIRE_STATE.store(0, Ordering::Relaxed);
// OBS releases objects asynchronously, so names must not be reused.
static RECORDINGS: AtomicU64 = AtomicU64::new(0);
let id = RECORDINGS.fetch_add(1, Ordering::Relaxed);
let name = |what: &str| cstring(&format!("clipbin-{id}-{what}"));
let name_prefix = format!("clipbin-{}", RECORDINGS.fetch_add(1, Ordering::Relaxed));
let name = |what: &str| cstring(&format!("{name_prefix}-{what}"));
// SAFETY: runs on the OBS thread. Each object is stored in `pipeline`
// as soon as it exists, so Drop releases it if a later step fails.
@@ -591,41 +630,30 @@ impl Pipeline {
let mut pipeline = Self {
// Private: internal to Clipbin, not registered with the main canvas.
scene: sys::obs_scene_create_private(name("scene").as_ptr()),
scene_item: ptr::null_mut(),
sources: Vec::new(),
video_source: ptr::null_mut(),
video_encoder: ptr::null_mut(),
audio_encoders: Vec::new(),
output: ptr::null_mut(),
signals: None,
microphone: capture.microphone.is_some(),
pending: Mutex::new(None),
output: Mutex::new(None),
name_prefix: name_prefix.clone(),
};
if let Some(source) = &spec.video_source {
if let Some(source) = &capture.video_source {
pipeline.video_source = create_source(source, &name("video"))?;
pipeline.sources.push((None, pipeline.video_source));
let item = sys::obs_scene_add(pipeline.scene, pipeline.video_source);
pipeline.scene_item = sys::obs_scene_add(pipeline.scene, pipeline.video_source);
sys::obs_sceneitem_set_bounds_type(
item,
pipeline.scene_item,
sys::obs_bounds_type_OBS_BOUNDS_SCALE_INNER,
);
let mut bounds: sys::vec2 = std::mem::zeroed();
bounds.__bindgen_anon_1.__bindgen_anon_1.x = spec.canvas.0 as f32;
bounds.__bindgen_anon_1.__bindgen_anon_1.y = spec.canvas.1 as f32;
sys::obs_sceneitem_set_bounds(item, &bounds);
}
sys::obs_set_output_source(VIDEO_CHANNEL, sys::obs_scene_get_source(pipeline.scene));
// With the microphone on, system and microphone audio get their
// own tracks for editing plus a combined track for playback. With
// only system audio, its single track is already the combined mix.
// The FFmpeg muxer needs at least one audio track, so recording
// without audio sources still writes a silent one.
let tracks: &[usize] = match &spec.microphone {
Some(_) => &[SYSTEM_TRACK, MICROPHONE_TRACK, COMBINED_TRACK],
None => &[SYSTEM_TRACK],
};
let tracks = audio_tracks(pipeline.microphone);
for (source_spec, channel, track) in [
(&spec.system_audio, SYSTEM_AUDIO_CHANNEL, SYSTEM_TRACK),
(&spec.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
(&capture.system_audio, SYSTEM_AUDIO_CHANNEL, SYSTEM_TRACK),
(&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
] {
if let Some(source_spec) = source_spec {
let source = create_source(source_spec, &name(&format!("audio-{channel}")))?;
@@ -639,135 +667,94 @@ impl Pipeline {
}
}
let encoder_settings = Data::new(&spec.video_encoder_settings);
pipeline.video_encoder = sys::obs_video_encoder_create(
cstring(&spec.video_encoder).as_ptr(),
name("video-encoder").as_ptr(),
encoder_settings.0,
ptr::null_mut(),
);
if pipeline.video_encoder.is_null() {
return Err(format!(
"could not create video encoder {}",
spec.video_encoder
));
}
sys::obs_encoder_set_video(pipeline.video_encoder, sys::obs_get_video());
for &track in tracks {
let settings =
Data::new(&[("bitrate", SettingValue::Int(spec.audio_bitrate_kbps))]);
let encoder = sys::obs_audio_encoder_create(
cstring("ffmpeg_aac").as_ptr(),
name(&format!("audio-encoder-{track}")).as_ptr(),
settings.0,
track,
ptr::null_mut(),
);
if encoder.is_null() {
return Err("could not create the AAC audio encoder".into());
}
pipeline.audio_encoders.push(encoder);
sys::obs_encoder_set_audio(encoder, sys::obs_get_audio());
}
let first_segment = format!(
"{}/{}.{}",
spec.segment_dir,
segment_stem(0),
spec.segment_extension
);
let output_settings = Data::new(&[
("path", SettingValue::String(first_segment.clone())),
("directory", SettingValue::String(spec.segment_dir.clone())),
("format", SettingValue::String(segment_stem(1))),
(
"extension",
SettingValue::String(spec.segment_extension.into()),
),
("split_file", SettingValue::Bool(true)),
("max_time_sec", SettingValue::Int(spec.segment_seconds)),
("max_size_mb", SettingValue::Int(0)),
("allow_overwrite", SettingValue::Bool(false)),
]);
pipeline.output = sys::obs_output_create(
cstring("ffmpeg_muxer").as_ptr(),
name("segments").as_ptr(),
output_settings.0,
ptr::null_mut(),
);
if pipeline.output.is_null() {
return Err("could not create the FFmpeg muxer output".into());
}
sys::obs_output_set_video_encoder(pipeline.output, pipeline.video_encoder);
for (index, encoder) in pipeline.audio_encoders.iter().enumerate() {
sys::obs_output_set_audio_encoder(pipeline.output, *encoder, index);
}
let signals = Box::new(SignalContext {
events: Mutex::new(events),
output: pipeline.output,
first_segment,
current_segment,
});
let handler = sys::obs_output_get_signal_handler(pipeline.output);
for (name, callback) in SIGNALS {
sys::signal_handler_connect(
handler,
cstring(name).as_ptr(),
Some(callback),
(&*signals as *const SignalContext).cast_mut().cast(),
);
}
pipeline.signals = Some(signals);
*pipeline.pending.get_mut().expect("not shared yet") = Some(output);
Ok(pipeline)
}
}
/// Starts writing segments. A `true` result only means OBS accepted the
/// request; recording is confirmed by `OutputStarted` and written frames.
pub(crate) fn start(&self) -> Result<(), String> {
// SAFETY: output is live for the Pipeline's lifetime.
fn source_size(&self) -> (u32, u32) {
if self.video_source.is_null() {
return (0, 0);
}
// SAFETY: video_source is live for the Pipeline's lifetime.
unsafe {
if sys::obs_output_start(self.output) {
Ok(())
} else {
Err(owned(sys::obs_output_get_last_error(self.output))
.unwrap_or_else(|| "the output did not start".into()))
}
(
sys::obs_source_get_width(self.video_source),
sys::obs_source_get_height(self.video_source),
)
}
}
pub(crate) fn stop(&self) {
// SAFETY: output is live; stopping an inactive output is a no-op.
unsafe { sys::obs_output_stop(self.output) }
/// Starts the segment output if it is not running yet. `Ok(None)` means it
/// was already started; `Ok(Some(size))` reports the recording size chosen.
pub(crate) fn start_output(&self) -> Result<Option<(u32, u32)>, String> {
let mut output = self.output.lock().map_err(|_| "recording state poisoned")?;
let Some(spec) = self
.pending
.lock()
.map_err(|_| "recording state poisoned")?
.take()
else {
return Ok(None);
};
let canvas = canvas_size(spec.resolution, self.source_size());
reset_video(canvas, spec.frames_per_second)?;
if !self.scene_item.is_null() {
// SAFETY: the scene item belongs to the live scene.
unsafe {
let mut bounds: sys::vec2 = std::mem::zeroed();
bounds.__bindgen_anon_1.__bindgen_anon_1.x = canvas.0 as f32;
bounds.__bindgen_anon_1.__bindgen_anon_1.y = canvas.1 as f32;
sys::obs_sceneitem_set_bounds(self.scene_item, &bounds);
}
}
let started = Output::build(&self.name_prefix, audio_tracks(self.microphone), spec)?;
started.start()?;
*output = Some(started);
Ok(Some(canvas))
}
/// OBS's view of the recording; `Recording::health` adds what reached disk.
pub(crate) fn health(&self) -> Health {
// SAFETY: all objects are live for the Pipeline's lifetime.
unsafe {
let (width, height) = if self.video_source.is_null() {
(0, 0)
} else {
(
sys::obs_source_get_width(self.video_source),
sys::obs_source_get_height(self.video_source),
)
};
Health {
source_width: width,
source_height: height,
pipewire: load_pipewire_state(),
output_active: sys::obs_output_active(self.output),
total_frames: sys::obs_output_get_total_frames(self.output).max(0) as u64,
dropped_frames: sys::obs_output_get_frames_dropped(self.output).max(0) as u64,
total_bytes: sys::obs_output_get_total_bytes(self.output),
lagged_frames: sys::obs_get_lagged_frames() as u64,
written_bytes: 0,
disk_available_bytes: None,
/// Stops the output. Returns false if it never started.
pub(crate) fn stop(&self) -> bool {
self.pending.lock().ok().map(|mut pending| pending.take());
match self.output.lock().ok().as_deref() {
Some(Some(output)) => {
// SAFETY: output is live; stopping an inactive output is a no-op.
unsafe { sys::obs_output_stop(output.output) };
true
}
_ => false,
}
}
/// OBS's view of the recording; `Recording::poll` adds what reached disk.
pub(crate) fn health(&self) -> Health {
let (source_width, source_height) = self.source_size();
let output = self.output.lock().ok();
let output = output.as_deref().and_then(Option::as_ref);
// SAFETY: the output, when present, is live for the Pipeline's lifetime.
let counters = output.map(|output| unsafe {
(
sys::obs_output_active(output.output),
sys::obs_output_get_total_frames(output.output).max(0) as u64,
sys::obs_output_get_frames_dropped(output.output).max(0) as u64,
sys::obs_output_get_total_bytes(output.output),
)
});
let (output_active, total_frames, dropped_frames, total_bytes) =
counters.unwrap_or((false, 0, 0, 0));
Health {
source_width,
source_height,
pipewire: load_pipewire_state(),
output_active,
total_frames,
dropped_frames,
total_bytes,
// SAFETY: a global counter.
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
written_bytes: 0,
disk_available_bytes: None,
}
}
@@ -792,9 +779,146 @@ impl Pipeline {
impl Drop for Pipeline {
fn drop(&mut self) {
// The output goes first: it holds the encoders reading the scene.
drop(self.output.get_mut().ok().and_then(Option::take));
// SAFETY: runs on the OBS thread (Recording drops the Pipeline there).
// Releases in reverse order of creation, after detaching callbacks;
// a partially built Pipeline has null or empty fields for later steps.
unsafe {
sys::obs_set_output_source(VIDEO_CHANNEL, ptr::null_mut());
for (channel, source) in &self.sources {
if let Some(channel) = channel {
sys::obs_set_output_source(*channel, ptr::null_mut());
}
sys::obs_source_release(*source);
}
sys::obs_scene_release(self.scene);
}
}
}
/// Encoders and the FFmpeg muxer writing keyframe-aligned segments.
struct Output {
video_encoder: *mut sys::obs_encoder_t,
audio_encoders: Vec<*mut sys::obs_encoder_t>,
output: *mut sys::obs_output_t,
signals: Option<Box<SignalContext>>,
}
impl Output {
fn build(name_prefix: &str, tracks: &[usize], spec: OutputSpec) -> Result<Self, String> {
let name = |what: &str| cstring(&format!("{name_prefix}-{what}"));
// SAFETY: runs on the OBS thread. Each object is stored in `built` as
// soon as it exists, so Drop releases it if a later step fails.
unsafe {
let mut built = Self {
video_encoder: ptr::null_mut(),
audio_encoders: Vec::new(),
output: ptr::null_mut(),
signals: None,
};
let encoder_settings = Data::new(&spec.video_encoder_settings);
built.video_encoder = sys::obs_video_encoder_create(
cstring(&spec.video_encoder).as_ptr(),
name("video-encoder").as_ptr(),
encoder_settings.0,
ptr::null_mut(),
);
if built.video_encoder.is_null() {
return Err(format!(
"could not create video encoder {}",
spec.video_encoder
));
}
sys::obs_encoder_set_video(built.video_encoder, sys::obs_get_video());
for &track in tracks {
let settings =
Data::new(&[("bitrate", SettingValue::Int(spec.audio_bitrate_kbps))]);
let encoder = sys::obs_audio_encoder_create(
cstring("ffmpeg_aac").as_ptr(),
name(&format!("audio-encoder-{track}")).as_ptr(),
settings.0,
track,
ptr::null_mut(),
);
if encoder.is_null() {
return Err("could not create the AAC audio encoder".into());
}
built.audio_encoders.push(encoder);
sys::obs_encoder_set_audio(encoder, sys::obs_get_audio());
}
let first_segment = format!(
"{}/{}.{}",
spec.segment_dir,
segment_stem(0),
spec.segment_extension
);
let output_settings = Data::new(&[
("path", SettingValue::String(first_segment.clone())),
("directory", SettingValue::String(spec.segment_dir.clone())),
("format", SettingValue::String(segment_stem(1))),
(
"extension",
SettingValue::String(spec.segment_extension.into()),
),
("split_file", SettingValue::Bool(true)),
("max_time_sec", SettingValue::Int(spec.segment_seconds)),
("max_size_mb", SettingValue::Int(0)),
("allow_overwrite", SettingValue::Bool(false)),
]);
built.output = sys::obs_output_create(
cstring("ffmpeg_muxer").as_ptr(),
name("segments").as_ptr(),
output_settings.0,
ptr::null_mut(),
);
if built.output.is_null() {
return Err("could not create the FFmpeg muxer output".into());
}
sys::obs_output_set_video_encoder(built.output, built.video_encoder);
for (index, encoder) in built.audio_encoders.iter().enumerate() {
sys::obs_output_set_audio_encoder(built.output, *encoder, index);
}
let signals = Box::new(SignalContext {
events: Mutex::new(spec.events),
output: built.output,
first_segment,
current_segment: spec.current_segment,
});
let handler = sys::obs_output_get_signal_handler(built.output);
for (name, callback) in SIGNALS {
sys::signal_handler_connect(
handler,
cstring(name).as_ptr(),
Some(callback),
(&*signals as *const SignalContext).cast_mut().cast(),
);
}
built.signals = Some(signals);
Ok(built)
}
}
/// A `true` result only means OBS accepted the request; recording is
/// confirmed by `OutputStarted` and footage growing on disk.
fn start(&self) -> Result<(), String> {
// SAFETY: output is live.
unsafe {
if sys::obs_output_start(self.output) {
Ok(())
} else {
Err(owned(sys::obs_output_get_last_error(self.output))
.unwrap_or_else(|| "the output did not start".into()))
}
}
}
}
impl Drop for Output {
fn drop(&mut self) {
// SAFETY: runs on the OBS thread; detaches callbacks before releasing.
// A partially built Output has null or empty fields for later steps.
unsafe {
if !self.output.is_null() {
sys::obs_output_stop(self.output);
@@ -817,14 +941,6 @@ impl Drop for Pipeline {
for encoder in &self.audio_encoders {
sys::obs_encoder_release(*encoder);
}
sys::obs_set_output_source(VIDEO_CHANNEL, ptr::null_mut());
for (channel, source) in &self.sources {
if let Some(channel) = channel {
sys::obs_set_output_source(*channel, ptr::null_mut());
}
sys::obs_source_release(*source);
}
sys::obs_scene_release(self.scene);
}
}
}
@@ -882,6 +998,13 @@ mod tests {
assert_eq!(state("[portals] Request cancelled"), None);
}
#[test]
fn the_recording_follows_the_source_unless_configured() {
assert_eq!(canvas_size(None, (1440, 2560)), (1440, 2560));
assert_eq!(canvas_size(None, (1417, 2511)), (1416, 2510));
assert_eq!(canvas_size(Some((1920, 1080)), (1440, 2560)), (1920, 1080));
}
#[test]
fn segment_names_round_trip() {
assert_eq!(
+1 -1
View File
@@ -13,6 +13,6 @@ mod thread;
pub use recorder::{
Engine, EngineError, Health, PipeWireState, RecorderEvent, Recording, RecordingConfig,
SEGMENT_EXTENSION, StatusTracker, StopReason, VideoFormat,
SEGMENT_EXTENSION, StatusTracker, StopReason,
};
pub use runtime::{RuntimeError, RuntimeLayout};
+69 -37
View File
@@ -3,7 +3,7 @@ use std::{
path::{Path, PathBuf},
sync::{
Arc, Mutex,
mpsc::{self, Receiver},
mpsc::{self, Receiver, Sender},
},
time::{Duration, Instant},
};
@@ -14,7 +14,7 @@ use crate::{
RuntimeLayout,
capture::{SettingValue, SourceSpec},
encoders::registered_candidates,
ffi::{self, Pipeline, PipelineSpec, Session, StartupSpec},
ffi::{self, CaptureSpec, OutputSpec, Pipeline, Session, StartupSpec},
runtime::PLUGINS,
thread::ObsThread,
};
@@ -23,24 +23,18 @@ use crate::{
/// 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,
frames_per_second: u32,
#[cfg(target_os = "linux")]
_glib: ffi::GlibLoop,
}
impl Engine {
pub fn start(layout: &RuntimeLayout, format: VideoFormat) -> Result<Self, EngineError> {
/// Starts libobs. Each recording sets its own size once capture has a picture.
pub fn start(layout: &RuntimeLayout, frames_per_second: u32) -> Result<Self, EngineError> {
let path = |path: PathBuf| path.to_string_lossy().into_owned();
let spec = StartupSpec {
libobs_data_dir: path(layout.libobs_data_dir()),
@@ -53,9 +47,10 @@ impl Engine {
)
})
.collect(),
width: format.width,
height: format.height,
frames_per_second: format.frames_per_second,
// A placeholder canvas until a recording knows its source's size.
width: 1920,
height: 1080,
frames_per_second,
};
#[cfg(target_os = "linux")]
let glib = ffi::GlibLoop::start();
@@ -66,7 +61,7 @@ impl Engine {
Ok(Self {
obs,
session: Some(session),
format,
frames_per_second,
#[cfg(target_os = "linux")]
_glib: glib,
})
@@ -104,13 +99,21 @@ impl Engine {
.collect()
}
/// Starts capture. The segment output starts once the source has a
/// picture (see `Recording::poll`), so nothing is written before then.
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),
let (sender, events) = mpsc::channel();
let current_segment = Arc::new(Mutex::new(None));
let waits_for_picture = config.video_source.is_some();
let capture = CaptureSpec {
video_source: config.video_source,
system_audio: config.system_audio,
microphone: config.microphone,
};
let output = OutputSpec {
frames_per_second: self.frames_per_second,
resolution: config.resolution,
video_encoder: config.video_encoder,
video_encoder_settings: vec![
("rate_control", SettingValue::String("CBR".into())),
@@ -124,26 +127,23 @@ impl Engine {
segment_dir: forward_slashes(&config.segment_dir),
segment_extension: SEGMENT_EXTENSION,
segment_seconds: config.segment_seconds,
events: sender.clone(),
current_segment: Arc::clone(&current_segment),
};
let (sender, events) = mpsc::channel();
let current_segment = Arc::new(Mutex::new(None));
let segment = Arc::clone(&current_segment);
let pipeline = self
.obs
.run(move || Pipeline::build(&spec, sender, segment).map(Arc::new))
.run(move || Pipeline::build(&capture, output).map(Arc::new))
.map_err(EngineError::Pipeline)?;
let recording = Recording {
Ok(Recording {
obs: Arc::clone(&self.obs),
pipeline: Some(pipeline),
events,
sender,
waits_for_picture,
current_segment,
written: Mutex::default(),
segment_dir: config.segment_dir,
};
recording
.with_pipeline(|pipeline| pipeline.start())
.map_err(EngineError::Start)?;
Ok(recording)
})
}
}
@@ -164,6 +164,8 @@ pub struct RecordingConfig {
pub video_source: Option<SourceSpec>,
pub system_audio: Option<SourceSpec>,
pub microphone: Option<SourceSpec>,
/// The recording size; `None` follows the captured window or monitor.
pub resolution: Option<(u32, u32)>,
pub video_encoder: String,
pub video_bitrate_kbps: i64,
pub keyframe_interval_seconds: i64,
@@ -178,6 +180,9 @@ pub struct Recording {
obs: Arc<ObsThread>,
pipeline: Option<Arc<Pipeline>>,
events: Receiver<RecorderEvent>,
sender: Sender<RecorderEvent>,
/// Without a video source (encoder checks) there is no picture to wait for.
waits_for_picture: bool,
current_segment: Arc<Mutex<Option<String>>>,
written: Mutex<Written>,
segment_dir: PathBuf,
@@ -207,11 +212,23 @@ impl Recording {
&self.events
}
/// OBS's counters plus what reached disk. 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 {
/// Samples the recording, starting the segment output the first time the
/// source has a picture. Call it regularly (the probe uses once a second).
///
/// Health combines OBS's counters with what reached disk: OBS counts bytes
/// handed to its muxer process, which keep rising even if that process
/// cannot write, so only growth on disk proves footage is saved.
pub fn poll(&self) -> Result<Health, EngineError> {
let mut health = self.with_pipeline(Pipeline::health);
if (health.has_picture() || !self.waits_for_picture)
&& let Some((width, height)) = self
.with_pipeline(Pipeline::start_output)
.map_err(EngineError::Start)?
{
let _ = self
.sender
.send(RecorderEvent::OutputSized { width, height });
}
health.disk_available_bytes = fs4::available_space(&self.segment_dir).ok();
let current = self
.current_segment
@@ -230,16 +247,22 @@ impl Recording {
health.written_bytes =
written.finished_bytes + written.current.as_deref().map_or(0, size);
}
health
Ok(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.
/// Requests a stop; `OutputStopped` follows once the last segment is
/// finalised, or at once if the output never started.
pub fn stop(&self) {
self.with_pipeline(Pipeline::stop)
if !self.with_pipeline(Pipeline::stop) {
let _ = self.sender.send(RecorderEvent::OutputStopped {
code: libobs::OBS_OUTPUT_SUCCESS.into(),
last_error: None,
});
}
}
}
@@ -254,6 +277,8 @@ impl Drop for Recording {
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecorderEvent {
/// Capture has a picture and the output was created at this size.
OutputSized { width: u32, height: u32 },
/// 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.
@@ -293,6 +318,15 @@ pub enum PipeWireState {
Refused = 3,
}
impl Health {
/// Whether the capture source is delivering a picture.
pub fn has_picture(&self) -> bool {
self.source_width > 0
&& self.source_height > 0
&& matches!(self.pipewire, None | Some(PipeWireState::Streaming))
}
}
/// 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
@@ -350,9 +384,7 @@ impl StatusTracker {
};
return &self.status;
}
let has_picture = health.source_width > 0
&& health.source_height > 0
&& matches!(health.pipewire, None | Some(PipeWireState::Streaming));
let has_picture = health.has_picture();
let stalled = now.duration_since(self.last_progress) >= self.stall_limit;
if let Some(available) = health.disk_available_bytes
&& available < self.min_free_bytes
+29 -14
View File
@@ -7,7 +7,7 @@
//! 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]
//! [--width W --height H] [--fps F] [--no-audio] [--mic]
//! [--min-free-mb MB] [--out DIR]
//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--expect-motion] [--out DIR]
//!
@@ -24,7 +24,7 @@ use std::{
use clipbin_core::{RecordingStatus, VideoCodec};
use clipbin_obs::{
Engine, RecorderEvent, RecordingConfig, RuntimeLayout, StatusTracker, StopReason, VideoFormat,
Engine, RecorderEvent, RecordingConfig, RuntimeLayout, StatusTracker, StopReason,
capture::{self, CaptureTarget, MonitorMatch, WindowMatch},
encoders::ALL_CODECS,
};
@@ -73,7 +73,10 @@ struct Options {
segment_seconds: i64,
keyint: i64,
bitrate: i64,
format: VideoFormat,
/// The recording size; by default it follows the captured source.
width: Option<u32>,
height: Option<u32>,
frames_per_second: u32,
audio: bool,
mic: bool,
min_free_mb: u64,
@@ -94,11 +97,9 @@ impl Options {
segment_seconds: 2,
keyint: 1,
bitrate: 20_000,
format: VideoFormat {
width: 1920,
height: 1080,
frames_per_second: 60,
},
width: None,
height: None,
frames_per_second: 60,
audio: true,
mic: false,
min_free_mb: StatusTracker::DEFAULT_MIN_FREE_BYTES / 1_000_000,
@@ -124,9 +125,9 @@ impl Options {
"--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,
"--width" => options.width = Some(number(value()?)? as u32),
"--height" => options.height = Some(number(value()?)? as u32),
"--fps" => options.frames_per_second = number(value()?)? as u32,
"--no-audio" => options.audio = false,
"--mic" => options.mic = true,
"--min-free-mb" => options.min_free_mb = number(value()?)? as u64,
@@ -137,6 +138,14 @@ impl Options {
Ok(options)
}
fn resolution(&self) -> Result<Option<(u32, u32)>, String> {
match (self.width, self.height) {
(Some(width), Some(height)) => Ok(Some((width, height))),
(None, None) => Ok(None),
_ => Err("--width and --height go together".into()),
}
}
fn capture_target(&self) -> Result<CaptureTarget, String> {
let window = WindowMatch {
obs_window: self.window.clone(),
@@ -180,7 +189,7 @@ fn start_engine(options: &Options) -> Result<Engine, Box<dyn std::error::Error>>
"runtime_obs_version={}",
layout.version().unwrap_or_default()
);
let engine = Engine::start(&layout, options.format)?;
let engine = Engine::start(&layout, options.frames_per_second)?;
println!("obs_version={}", engine.obs_version());
for plugin in engine.plugins_failed() {
println!("plugin_not_initialised={plugin}");
@@ -215,6 +224,7 @@ fn encoders(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
video_source: None,
system_audio: None,
microphone: None,
resolution: Some(options.resolution()?.unwrap_or((1920, 1080))),
video_encoder: encoder.clone(),
video_bitrate_kbps: options.bitrate,
keyframe_interval_seconds: options.keyint,
@@ -245,8 +255,9 @@ fn verify_encoder(engine: &Engine, config: RecordingConfig) -> Result<u64, Strin
let recording = engine
.start_recording(config)
.map_err(|error| error.to_string())?;
recording.poll().map_err(|error| error.to_string())?;
std::thread::sleep(Duration::from_secs(3));
let health = recording.health();
let health = recording.poll().map_err(|error| error.to_string())?;
recording.stop();
let stop = wait_for_stop(&recording, Duration::from_secs(10));
match stop {
@@ -311,6 +322,7 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
video_source: Some(video_source),
system_audio: options.audio.then(capture::system_audio_source),
microphone: options.mic.then(capture::microphone_source),
resolution: options.resolution()?,
video_encoder: encoder,
video_bitrate_kbps: options.bitrate,
keyframe_interval_seconds: options.keyint,
@@ -338,7 +350,7 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
let now = Instant::now();
if now >= next_sample {
next_sample += Duration::from_secs(1);
let health = recording.health();
let health = recording.poll()?;
println!(
"t={} health source={}x{} pipewire={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}",
elapsed(),
@@ -361,6 +373,9 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
recording.stop();
}
match recording.events().recv_timeout(Duration::from_millis(100)) {
Ok(RecorderEvent::OutputSized { width, height }) => {
println!("t={} recording_size={width}x{height}", elapsed())
}
Ok(RecorderEvent::OutputStarted) => println!("t={} output_started", elapsed()),
Ok(RecorderEvent::SegmentStarted { path }) => {
println!("t={} segment_started={path}", elapsed())