From 795118c200a394905a5afe36a2e66e4749789c4f Mon Sep 17 00:00:00 2001 From: Harry Date: Thu, 8 Oct 2026 15:34:42 +0200 Subject: [PATCH] feat: record at the source's size, only once capture works 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. --- SPEC.md | 5 +- crates/clipbin-core/src/settings.rs | 8 +- crates/clipbin-desktop/src/view.rs | 9 +- crates/clipbin-obs/src/ffi.rs | 495 +++++++++++++++++----------- crates/clipbin-obs/src/lib.rs | 2 +- crates/clipbin-obs/src/recorder.rs | 106 +++--- crates/clipbin-probe/src/main.rs | 43 ++- docs/validation.md | 34 +- scripts/validate/linux-rig.sh | 6 +- 9 files changed, 448 insertions(+), 260 deletions(-) diff --git a/SPEC.md b/SPEC.md index 24b88df..f3d1ff7 100644 --- a/SPEC.md +++ b/SPEC.md @@ -35,8 +35,9 @@ Build an open-source desktop game recorder inspired by Plays.tv, using Rust, GPU ## Recording defaults and audio -- Default video: 1080p at 60 FPS. -- Resolution and frame rate are configurable, including the user's intended 1440p60 configuration. +- Default video: the captured window's or monitor's own size (following its orientation) at 60 FPS. Decided 2026-10-08, replacing the original 1080p default. +- A fixed resolution and the frame rate are configurable, including the user's intended 1440p60 configuration. +- Recording writes footage only once capture provides a picture. Time spent waiting for a source (such as the screen-share picker) produces no footage. Decided 2026-10-08. - Hardware video codec choices: H.264, HEVC, and AV1. H.264 is the default. - Offer a codec as usable only when the runtime/device supports its hardware encoder. Failure to obtain a working encoder follows the honest-status and failure-alert rules. - Keep system audio enabled by default. diff --git a/crates/clipbin-core/src/settings.rs b/crates/clipbin-core/src/settings.rs index 6bbfaad..b2068b9 100644 --- a/crates/clipbin-core/src/settings.rs +++ b/crates/clipbin-core/src/settings.rs @@ -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, diff --git a/crates/clipbin-desktop/src/view.rs b/crates/clipbin-desktop/src/view.rs index 760460e..680b917 100644 --- a/crates/clipbin-desktop/src/view.rs +++ b/crates/clipbin-desktop/src/view.rs @@ -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", diff --git a/crates/clipbin-obs/src/ffi.rs b/crates/clipbin-obs/src/ffi.rs index 8248763..46b4bf4 100644 --- a/crates/clipbin-obs/src/ffi.rs +++ b/crates/clipbin-obs/src/ffi.rs @@ -224,28 +224,9 @@ pub(crate) fn startup(spec: &StartupSpec) -> Result { 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 { } } +/// 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, pub system_audio: Option, pub microphone: Option, +} + +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, + pub current_segment: Arc>>, } /// 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 { 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, *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>, + microphone: bool, + pending: Mutex>, + output: Mutex>, } // 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, - current_segment: Arc>>, - ) -> Result { + pub(crate) fn build(capture: &CaptureSpec, output: OutputSpec) -> Result { 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, 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>, +} + +impl Output { + fn build(name_prefix: &str, tracks: &[usize], spec: OutputSpec) -> Result { + 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!( diff --git a/crates/clipbin-obs/src/lib.rs b/crates/clipbin-obs/src/lib.rs index 4e62b9a..8778760 100644 --- a/crates/clipbin-obs/src/lib.rs +++ b/crates/clipbin-obs/src/lib.rs @@ -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}; diff --git a/crates/clipbin-obs/src/recorder.rs b/crates/clipbin-obs/src/recorder.rs index 1505f64..6cff6aa 100644 --- a/crates/clipbin-obs/src/recorder.rs +++ b/crates/clipbin-obs/src/recorder.rs @@ -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, session: Option, - format: VideoFormat, + frames_per_second: u32, #[cfg(target_os = "linux")] _glib: ffi::GlibLoop, } impl Engine { - pub fn start(layout: &RuntimeLayout, format: VideoFormat) -> Result { + /// Starts libobs. Each recording sets its own size once capture has a picture. + pub fn start(layout: &RuntimeLayout, frames_per_second: u32) -> Result { 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 { 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(¤t_segment), }; - 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)) + .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, pub system_audio: Option, pub microphone: Option, + /// 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, pipeline: Option>, events: Receiver, + sender: Sender, + /// Without a video source (encoder checks) there is no picture to wait for. + waits_for_picture: bool, current_segment: Arc>>, written: Mutex, 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 { 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 { 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 diff --git a/crates/clipbin-probe/src/main.rs b/crates/clipbin-probe/src/main.rs index 6c15c09..4487ff9 100644 --- a/crates/clipbin-probe/src/main.rs +++ b/crates/clipbin-probe/src/main.rs @@ -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, + height: Option, + 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, 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 { let window = WindowMatch { obs_window: self.window.clone(), @@ -180,7 +189,7 @@ fn start_engine(options: &Options) -> Result> "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> { 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 Result> { 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> { 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> { 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()) diff --git a/docs/validation.md b/docs/validation.md index f5b3bb5..fea8864 100644 --- a/docs/validation.md +++ b/docs/validation.md @@ -149,18 +149,28 @@ Windows status: its last frame after the stream stops, and only logs the stream state. The engine reads that log line (stable for the pinned OBS version). A more robust follow-up is watching the PipeWire node state directly. -- **Closed windows on Wayland.** With Hyprland's portal, closing a captured - window leaves the stream running on its last frame, with no state change or - error. Capture cannot tell this apart from a static window, so sessions must - end from game process exit (SPEC: "Stop/finalise the session when its game - exits"), which the Steam/process detection milestone provides. -- **Writing while waiting for capture.** The output starts before the source - has a picture, so a source that never arrives produces black segments. The - session timeline should show that time as missing footage, or the output - should start only once capture is usable. -- **Canvas orientation.** The output canvas is fixed by the configured - resolution, so portrait or other-aspect sources are letterboxed. Decide - whether the canvas should follow the captured source. +- **Ending a session when the game closes.** With Hyprland's portal, closing a + captured window leaves the stream running on its last frame, with no state + change or error, so capture cannot tell it from a static window. Monitor + capture never sees the game close at all. The session therefore ends from + the game's process exiting (SPEC: "Stop/finalise the session when its game + exits"); capture loss stays a failure signal, not the normal end: + - Linux: Steam runs each game under `reaper SteamLaunch AppId=`, which + lives exactly as long as the game (Proton included). Watch that process + with a pidfd (`pidfd_open` + poll) rather than polling `/proc`. + - Windows: watch the game's process handle (`OpenProcess` + + `WaitForSingleObject`), found from Steam's `RunningAppID` registry value. + Game Capture reports size 0 when its game closes; the exit must be seen + first so a normal exit finalises the session instead of failing it. + - Hyprland's IPC does announce window closes, but it is compositor-specific + and cannot map a portal stream to a window, so it is not used. +- **Nothing is written before capture works.** Encoders and the segment + output are created only once the source has a picture, so waiting in the + picker or for a stalled portal writes no footage. +- **Recording size.** By default each recording is the captured window's or + monitor's own size, set when capture first has a picture (portrait monitors + record portrait). If the window is resized mid-session the size stays and + the picture is scaled to fit. - **Audio capture failures are not detected yet.** When the PulseAudio server was unreachable, recording continued with a silent track. - **Linux runtime dependencies.** The Linux OBS build links the system's diff --git a/scripts/validate/linux-rig.sh b/scripts/validate/linux-rig.sh index a528ff8..d44fbe6 100755 --- a/scripts/validate/linux-rig.sh +++ b/scripts/validate/linux-rig.sh @@ -101,13 +101,15 @@ else result "${name}" 1 fi -# 8. The portal refuses the request: an actionable failure, never Recording. +# 8. The portal refuses the request: an actionable failure, never Recording, +# and no footage written (output starts only once capture has a picture). name=portal-refused CLIPBIN_RIG_PORTAL_OUTPUT=NO-SUCH-OUTPUT "${rig}" "${probe}" record --seconds 8 --out "${out}/${name}" \ > "${out}/${name}.log" 2>&1 probe_exit=$? if [[ "${probe_exit}" != 0 ]] && ! has "${out}/${name}.log" "status=Recording" \ - && has "${out}/${name}.log" "status=failed reason=screen capture was not allowed"; then + && has "${out}/${name}.log" "status=failed reason=screen capture was not allowed" \ + && [[ -z "$(ls -A "${out}/${name}/session" 2>/dev/null)" ]]; then result "${name}" 0 "$(grep -m1 -o 'status=failed reason=.*' "${out}/${name}.log")" else result "${name}" 1