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
+3 -2
View File
@@ -35,8 +35,9 @@ Build an open-source desktop game recorder inspired by Plays.tv, using Rust, GPU
## Recording defaults and audio ## Recording defaults and audio
- Default video: 1080p at 60 FPS. - 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.
- Resolution and frame rate are configurable, including the user's intended 1440p60 configuration. - 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. - 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. - 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. - Keep system audio enabled by default.
+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. /// In-memory defaults from SPEC.md. Persistence and editing come with the recorder.
#[derive(Debug, Clone, PartialEq, Eq)] #[derive(Debug, Clone, PartialEq, Eq)]
pub struct Settings { pub struct Settings {
pub width: u32, /// A fixed recording size. `None` records at the captured window's or
pub height: u32, /// monitor's own size, following its orientation.
pub resolution: Option<(u32, u32)>,
pub frames_per_second: u32, pub frames_per_second: u32,
pub codec: VideoCodec, pub codec: VideoCodec,
pub system_audio_enabled: bool, pub system_audio_enabled: bool,
@@ -38,8 +39,7 @@ pub struct Settings {
impl Default for Settings { impl Default for Settings {
fn default() -> Self { fn default() -> Self {
Self { Self {
width: 1920, resolution: None,
height: 1080,
frames_per_second: 60, frames_per_second: 60,
codec: VideoCodec::H264, codec: VideoCodec::H264,
system_audio_enabled: true, system_audio_enabled: true,
+7 -2
View File
@@ -78,8 +78,13 @@ impl Render for ClipbinView {
.text_sm() .text_sm()
.text_color(rgb(0xa9b2c2)) .text_color(rgb(0xa9b2c2))
.child(format!( .child(format!(
"Recording defaults: {} × {} · {} FPS · Hardware {}", "Recording defaults: {} · {} FPS · Hardware {}",
settings.width, settings.height, settings.frames_per_second, settings.codec, match settings.resolution {
Some((width, height)) => format!("{width} × {height}"),
None => "Captured window size".into(),
},
settings.frames_per_second,
settings.codec,
)) ))
.child(format!( .child(format!(
"Session budget: {} GB of unprotected footage · Clip: {} seconds", "Session budget: {} GB of unprotected footage · Clip: {} seconds",
+306 -183
View File
@@ -224,28 +224,9 @@ pub(crate) fn startup(spec: &StartupSpec) -> Result<Session, String> {
return Err("libobs could not set up audio".into()); return Err("libobs could not set up audio".into());
} }
let graphics_module = cstring(if cfg!(windows) { if let Err(error) = reset_video((spec.width, spec.height), spec.frames_per_second) {
"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 {
sys::obs_shutdown(); 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 // 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) { pub(crate) fn shutdown(session: Session) {
// SAFETY: runs on the OBS thread after every OBS object was released. // SAFETY: runs on the OBS thread after every OBS object was released.
// Released sources are destroyed on a libobs task thread, and destroying // 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 encoders
} }
pub(crate) struct PipelineSpec { pub(crate) struct CaptureSpec {
pub canvas: (u32, u32),
pub video_source: Option<SourceSpec>, pub video_source: Option<SourceSpec>,
pub system_audio: Option<SourceSpec>, pub system_audio: Option<SourceSpec>,
pub microphone: 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: String,
pub video_encoder_settings: Vec<(&'static str, SettingValue)>, pub video_encoder_settings: Vec<(&'static str, SettingValue)>,
pub audio_bitrate_kbps: i64, pub audio_bitrate_kbps: i64,
pub segment_dir: String, pub segment_dir: String,
pub segment_extension: &'static str, pub segment_extension: &'static str,
pub segment_seconds: i64, 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. /// 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 MICROPHONE_TRACK: usize = 1;
pub(crate) const COMBINED_TRACK: usize = 2; 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 { pub(crate) fn segment_stem(index: u64) -> String {
format!("segment-{index:06}") format!("segment-{index:06}")
} }
@@ -505,7 +545,7 @@ const SIGNALS: [(
]; ];
unsafe extern "C" fn on_start(data: *mut c_void, _: *mut sys::calldata_t) { 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. // outlives the connection.
let context = unsafe { &*(data as *const SignalContext) }; let context = unsafe { &*(data as *const SignalContext) };
context.send(RecorderEvent::OutputStarted); context.send(RecorderEvent::OutputStarted);
@@ -557,16 +597,19 @@ pub(crate) fn segment_index(path: &str) -> Option<u64> {
stem.strip_prefix("segment-")?.parse().ok() 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 { pub(crate) struct Pipeline {
name_prefix: String,
scene: *mut sys::obs_scene_t, 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. /// Each source with the output channel it is bound to, if any.
sources: Vec<(Option<u32>, *mut sys::obs_source_t)>, sources: Vec<(Option<u32>, *mut sys::obs_source_t)>,
video_source: *mut sys::obs_source_t, video_source: *mut sys::obs_source_t,
video_encoder: *mut sys::obs_encoder_t, microphone: bool,
audio_encoders: Vec<*mut sys::obs_encoder_t>, pending: Mutex<Option<OutputSpec>>,
output: *mut sys::obs_output_t, output: Mutex<Option<Output>>,
signals: Option<Box<SignalContext>>,
} }
// SAFETY: the pointers are only dereferenced on the OBS thread (see module docs). // 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 {} unsafe impl Sync for Pipeline {}
impl Pipeline { impl Pipeline {
pub(crate) fn build( pub(crate) fn build(capture: &CaptureSpec, output: OutputSpec) -> Result<Self, String> {
spec: &PipelineSpec,
events: Sender<RecorderEvent>,
current_segment: Arc<Mutex<Option<String>>>,
) -> Result<Self, String> {
PIPEWIRE_STATE.store(0, Ordering::Relaxed); PIPEWIRE_STATE.store(0, Ordering::Relaxed);
// OBS releases objects asynchronously, so names must not be reused. // OBS releases objects asynchronously, so names must not be reused.
static RECORDINGS: AtomicU64 = AtomicU64::new(0); static RECORDINGS: AtomicU64 = AtomicU64::new(0);
let id = RECORDINGS.fetch_add(1, Ordering::Relaxed); let name_prefix = format!("clipbin-{}", RECORDINGS.fetch_add(1, Ordering::Relaxed));
let name = |what: &str| cstring(&format!("clipbin-{id}-{what}")); let name = |what: &str| cstring(&format!("{name_prefix}-{what}"));
// SAFETY: runs on the OBS thread. Each object is stored in `pipeline` // 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. // as soon as it exists, so Drop releases it if a later step fails.
@@ -591,41 +630,30 @@ impl Pipeline {
let mut pipeline = Self { let mut pipeline = Self {
// Private: internal to Clipbin, not registered with the main canvas. // Private: internal to Clipbin, not registered with the main canvas.
scene: sys::obs_scene_create_private(name("scene").as_ptr()), scene: sys::obs_scene_create_private(name("scene").as_ptr()),
scene_item: ptr::null_mut(),
sources: Vec::new(), sources: Vec::new(),
video_source: ptr::null_mut(), video_source: ptr::null_mut(),
video_encoder: ptr::null_mut(), microphone: capture.microphone.is_some(),
audio_encoders: Vec::new(), pending: Mutex::new(None),
output: ptr::null_mut(), output: Mutex::new(None),
signals: 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.video_source = create_source(source, &name("video"))?;
pipeline.sources.push((None, pipeline.video_source)); 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( sys::obs_sceneitem_set_bounds_type(
item, pipeline.scene_item,
sys::obs_bounds_type_OBS_BOUNDS_SCALE_INNER, 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)); sys::obs_set_output_source(VIDEO_CHANNEL, sys::obs_scene_get_source(pipeline.scene));
// With the microphone on, system and microphone audio get their let tracks = audio_tracks(pipeline.microphone);
// 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],
};
for (source_spec, channel, track) in [ for (source_spec, channel, track) in [
(&spec.system_audio, SYSTEM_AUDIO_CHANNEL, SYSTEM_TRACK), (&capture.system_audio, SYSTEM_AUDIO_CHANNEL, SYSTEM_TRACK),
(&spec.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK), (&capture.microphone, MICROPHONE_CHANNEL, MICROPHONE_TRACK),
] { ] {
if let Some(source_spec) = source_spec { if let Some(source_spec) = source_spec {
let source = create_source(source_spec, &name(&format!("audio-{channel}")))?; let source = create_source(source_spec, &name(&format!("audio-{channel}")))?;
@@ -639,137 +667,96 @@ impl Pipeline {
} }
} }
let encoder_settings = Data::new(&spec.video_encoder_settings); *pipeline.pending.get_mut().expect("not shared yet") = Some(output);
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);
Ok(pipeline) Ok(pipeline)
} }
} }
/// Starts writing segments. A `true` result only means OBS accepted the fn source_size(&self) -> (u32, u32) {
/// request; recording is confirmed by `OutputStarted` and written frames. if self.video_source.is_null() {
pub(crate) fn start(&self) -> Result<(), String> { return (0, 0);
// SAFETY: output is live for the Pipeline's lifetime. }
// SAFETY: video_source is live for the Pipeline's lifetime.
unsafe { 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()))
}
}
}
pub(crate) fn stop(&self) {
// SAFETY: output is live; stopping an inactive output is a no-op.
unsafe { sys::obs_output_stop(self.output) }
}
/// 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_width(self.video_source),
sys::obs_source_get_height(self.video_source), sys::obs_source_get_height(self.video_source),
) )
}
}
/// 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))
}
/// 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 { Health {
source_width: width, source_width,
source_height: height, source_height,
pipewire: load_pipewire_state(), pipewire: load_pipewire_state(),
output_active: sys::obs_output_active(self.output), output_active,
total_frames: sys::obs_output_get_total_frames(self.output).max(0) as u64, total_frames,
dropped_frames: sys::obs_output_get_frames_dropped(self.output).max(0) as u64, dropped_frames,
total_bytes: sys::obs_output_get_total_bytes(self.output), total_bytes,
lagged_frames: sys::obs_get_lagged_frames() as u64, // SAFETY: a global counter.
lagged_frames: unsafe { sys::obs_get_lagged_frames() } as u64,
written_bytes: 0, written_bytes: 0,
disk_available_bytes: None, disk_available_bytes: None,
} }
} }
}
/// The portal restore token PipeWire capture saved after the user's /// The portal restore token PipeWire capture saved after the user's
/// selection, so the next session can restore it without asking again. /// selection, so the next session can restore it without asking again.
@@ -792,9 +779,146 @@ impl Pipeline {
impl Drop for Pipeline { impl Drop for Pipeline {
fn drop(&mut self) { 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). // SAFETY: runs on the OBS thread (Recording drops the Pipeline there).
// Releases in reverse order of creation, after detaching callbacks; unsafe {
// a partially built Pipeline has null or empty fields for later steps. 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 { unsafe {
if !self.output.is_null() { if !self.output.is_null() {
sys::obs_output_stop(self.output); sys::obs_output_stop(self.output);
@@ -817,14 +941,6 @@ impl Drop for Pipeline {
for encoder in &self.audio_encoders { for encoder in &self.audio_encoders {
sys::obs_encoder_release(*encoder); 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); 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] #[test]
fn segment_names_round_trip() { fn segment_names_round_trip() {
assert_eq!( assert_eq!(
+1 -1
View File
@@ -13,6 +13,6 @@ mod thread;
pub use recorder::{ pub use recorder::{
Engine, EngineError, Health, PipeWireState, RecorderEvent, Recording, RecordingConfig, Engine, EngineError, Health, PipeWireState, RecorderEvent, Recording, RecordingConfig,
SEGMENT_EXTENSION, StatusTracker, StopReason, VideoFormat, SEGMENT_EXTENSION, StatusTracker, StopReason,
}; };
pub use runtime::{RuntimeError, RuntimeLayout}; pub use runtime::{RuntimeError, RuntimeLayout};
+69 -37
View File
@@ -3,7 +3,7 @@ use std::{
path::{Path, PathBuf}, path::{Path, PathBuf},
sync::{ sync::{
Arc, Mutex, Arc, Mutex,
mpsc::{self, Receiver}, mpsc::{self, Receiver, Sender},
}, },
time::{Duration, Instant}, time::{Duration, Instant},
}; };
@@ -14,7 +14,7 @@ use crate::{
RuntimeLayout, RuntimeLayout,
capture::{SettingValue, SourceSpec}, capture::{SettingValue, SourceSpec},
encoders::registered_candidates, encoders::registered_candidates,
ffi::{self, Pipeline, PipelineSpec, Session, StartupSpec}, ffi::{self, CaptureSpec, OutputSpec, Pipeline, Session, StartupSpec},
runtime::PLUGINS, runtime::PLUGINS,
thread::ObsThread, thread::ObsThread,
}; };
@@ -23,24 +23,18 @@ use crate::{
/// export remuxes to MP4 without re-encoding. /// export remuxes to MP4 without re-encoding.
pub const SEGMENT_EXTENSION: &str = "mkv"; 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. /// The single libobs instance. Dropping it shuts OBS down.
pub struct Engine { pub struct Engine {
obs: Arc<ObsThread>, obs: Arc<ObsThread>,
session: Option<Session>, session: Option<Session>,
format: VideoFormat, frames_per_second: u32,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
_glib: ffi::GlibLoop, _glib: ffi::GlibLoop,
} }
impl Engine { 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 path = |path: PathBuf| path.to_string_lossy().into_owned();
let spec = StartupSpec { let spec = StartupSpec {
libobs_data_dir: path(layout.libobs_data_dir()), libobs_data_dir: path(layout.libobs_data_dir()),
@@ -53,9 +47,10 @@ impl Engine {
) )
}) })
.collect(), .collect(),
width: format.width, // A placeholder canvas until a recording knows its source's size.
height: format.height, width: 1920,
frames_per_second: format.frames_per_second, height: 1080,
frames_per_second,
}; };
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
let glib = ffi::GlibLoop::start(); let glib = ffi::GlibLoop::start();
@@ -66,7 +61,7 @@ impl Engine {
Ok(Self { Ok(Self {
obs, obs,
session: Some(session), session: Some(session),
format, frames_per_second,
#[cfg(target_os = "linux")] #[cfg(target_os = "linux")]
_glib: glib, _glib: glib,
}) })
@@ -104,13 +99,21 @@ impl Engine {
.collect() .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> { pub fn start_recording(&self, config: RecordingConfig) -> Result<Recording, EngineError> {
std::fs::create_dir_all(&config.segment_dir).map_err(EngineError::Io)?; std::fs::create_dir_all(&config.segment_dir).map_err(EngineError::Io)?;
let spec = PipelineSpec { let (sender, events) = mpsc::channel();
canvas: (self.format.width, self.format.height), 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, video_source: config.video_source,
system_audio: config.system_audio, system_audio: config.system_audio,
microphone: config.microphone, microphone: config.microphone,
};
let output = OutputSpec {
frames_per_second: self.frames_per_second,
resolution: config.resolution,
video_encoder: config.video_encoder, video_encoder: config.video_encoder,
video_encoder_settings: vec![ video_encoder_settings: vec![
("rate_control", SettingValue::String("CBR".into())), ("rate_control", SettingValue::String("CBR".into())),
@@ -124,26 +127,23 @@ impl Engine {
segment_dir: forward_slashes(&config.segment_dir), segment_dir: forward_slashes(&config.segment_dir),
segment_extension: SEGMENT_EXTENSION, segment_extension: SEGMENT_EXTENSION,
segment_seconds: config.segment_seconds, 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 let pipeline = self
.obs .obs
.run(move || Pipeline::build(&spec, sender, segment).map(Arc::new)) .run(move || Pipeline::build(&capture, output).map(Arc::new))
.map_err(EngineError::Pipeline)?; .map_err(EngineError::Pipeline)?;
let recording = Recording { Ok(Recording {
obs: Arc::clone(&self.obs), obs: Arc::clone(&self.obs),
pipeline: Some(pipeline), pipeline: Some(pipeline),
events, events,
sender,
waits_for_picture,
current_segment, current_segment,
written: Mutex::default(), written: Mutex::default(),
segment_dir: config.segment_dir, 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 video_source: Option<SourceSpec>,
pub system_audio: Option<SourceSpec>, pub system_audio: Option<SourceSpec>,
pub microphone: 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_encoder: String,
pub video_bitrate_kbps: i64, pub video_bitrate_kbps: i64,
pub keyframe_interval_seconds: i64, pub keyframe_interval_seconds: i64,
@@ -178,6 +180,9 @@ pub struct Recording {
obs: Arc<ObsThread>, obs: Arc<ObsThread>,
pipeline: Option<Arc<Pipeline>>, pipeline: Option<Arc<Pipeline>>,
events: Receiver<RecorderEvent>, 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>>>, current_segment: Arc<Mutex<Option<String>>>,
written: Mutex<Written>, written: Mutex<Written>,
segment_dir: PathBuf, segment_dir: PathBuf,
@@ -207,11 +212,23 @@ impl Recording {
&self.events &self.events
} }
/// OBS's counters plus what reached disk. OBS counts bytes handed to its /// Samples the recording, starting the segment output the first time the
/// muxer process, which keeps rising even if that process cannot write, so /// source has a picture. Call it regularly (the probe uses once a second).
/// only growth on disk proves footage is saved. ///
pub fn health(&self) -> Health { /// 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); 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(); health.disk_available_bytes = fs4::available_space(&self.segment_dir).ok();
let current = self let current = self
.current_segment .current_segment
@@ -230,16 +247,22 @@ impl Recording {
health.written_bytes = health.written_bytes =
written.finished_bytes + written.current.as_deref().map_or(0, size); written.finished_bytes + written.current.as_deref().map_or(0, size);
} }
health Ok(health)
} }
pub fn restore_token(&self) -> Option<String> { pub fn restore_token(&self) -> Option<String> {
self.with_pipeline(Pipeline::restore_token) 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) { 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)] #[derive(Debug, Clone, PartialEq, Eq)]
pub enum RecorderEvent { 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. /// OBS reports the output as started. Not yet proof that footage is written.
OutputStarted, OutputStarted,
/// The muxer opened a new segment file; the previous one is complete. /// The muxer opened a new segment file; the previous one is complete.
@@ -293,6 +318,15 @@ pub enum PipeWireState {
Refused = 3, 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. /// 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 /// Recording is reported only while the source has a picture and encoded data
@@ -350,9 +384,7 @@ impl StatusTracker {
}; };
return &self.status; return &self.status;
} }
let has_picture = health.source_width > 0 let has_picture = health.has_picture();
&& health.source_height > 0
&& matches!(health.pipewire, None | Some(PipeWireState::Streaming));
let stalled = now.duration_since(self.last_progress) >= self.stall_limit; let stalled = now.duration_since(self.last_progress) >= self.stall_limit;
if let Some(available) = health.disk_available_bytes if let Some(available) = health.disk_available_bytes
&& available < self.min_free_bytes && available < self.min_free_bytes
+28 -13
View File
@@ -7,7 +7,7 @@
//! clipbin-probe record [--target monitor|window|game] [--window OBS_WINDOW] //! clipbin-probe record [--target monitor|window|game] [--window OBS_WINDOW]
//! [--restore-token TOKEN] [--codec h264|hevc|av1] [--encoder ID] //! [--restore-token TOKEN] [--codec h264|hevc|av1] [--encoder ID]
//! [--seconds N] [--segment-seconds N] [--keyint N] [--bitrate KBPS] //! [--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] //! [--min-free-mb MB] [--out DIR]
//! clipbin-probe check [--codec h264|hevc|av1] [--segment-seconds N] [--expect-motion] [--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_core::{RecordingStatus, VideoCodec};
use clipbin_obs::{ use clipbin_obs::{
Engine, RecorderEvent, RecordingConfig, RuntimeLayout, StatusTracker, StopReason, VideoFormat, Engine, RecorderEvent, RecordingConfig, RuntimeLayout, StatusTracker, StopReason,
capture::{self, CaptureTarget, MonitorMatch, WindowMatch}, capture::{self, CaptureTarget, MonitorMatch, WindowMatch},
encoders::ALL_CODECS, encoders::ALL_CODECS,
}; };
@@ -73,7 +73,10 @@ struct Options {
segment_seconds: i64, segment_seconds: i64,
keyint: i64, keyint: i64,
bitrate: 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, audio: bool,
mic: bool, mic: bool,
min_free_mb: u64, min_free_mb: u64,
@@ -94,11 +97,9 @@ impl Options {
segment_seconds: 2, segment_seconds: 2,
keyint: 1, keyint: 1,
bitrate: 20_000, bitrate: 20_000,
format: VideoFormat { width: None,
width: 1920, height: None,
height: 1080,
frames_per_second: 60, frames_per_second: 60,
},
audio: true, audio: true,
mic: false, mic: false,
min_free_mb: StatusTracker::DEFAULT_MIN_FREE_BYTES / 1_000_000, min_free_mb: StatusTracker::DEFAULT_MIN_FREE_BYTES / 1_000_000,
@@ -124,9 +125,9 @@ impl Options {
"--segment-seconds" => options.segment_seconds = number(value()?)?, "--segment-seconds" => options.segment_seconds = number(value()?)?,
"--keyint" => options.keyint = number(value()?)?, "--keyint" => options.keyint = number(value()?)?,
"--bitrate" => options.bitrate = number(value()?)?, "--bitrate" => options.bitrate = number(value()?)?,
"--width" => options.format.width = number(value()?)? as u32, "--width" => options.width = Some(number(value()?)? as u32),
"--height" => options.format.height = number(value()?)? as u32, "--height" => options.height = Some(number(value()?)? as u32),
"--fps" => options.format.frames_per_second = number(value()?)? as u32, "--fps" => options.frames_per_second = number(value()?)? as u32,
"--no-audio" => options.audio = false, "--no-audio" => options.audio = false,
"--mic" => options.mic = true, "--mic" => options.mic = true,
"--min-free-mb" => options.min_free_mb = number(value()?)? as u64, "--min-free-mb" => options.min_free_mb = number(value()?)? as u64,
@@ -137,6 +138,14 @@ impl Options {
Ok(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> { fn capture_target(&self) -> Result<CaptureTarget, String> {
let window = WindowMatch { let window = WindowMatch {
obs_window: self.window.clone(), obs_window: self.window.clone(),
@@ -180,7 +189,7 @@ fn start_engine(options: &Options) -> Result<Engine, Box<dyn std::error::Error>>
"runtime_obs_version={}", "runtime_obs_version={}",
layout.version().unwrap_or_default() 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()); println!("obs_version={}", engine.obs_version());
for plugin in engine.plugins_failed() { for plugin in engine.plugins_failed() {
println!("plugin_not_initialised={plugin}"); println!("plugin_not_initialised={plugin}");
@@ -215,6 +224,7 @@ fn encoders(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
video_source: None, video_source: None,
system_audio: None, system_audio: None,
microphone: None, microphone: None,
resolution: Some(options.resolution()?.unwrap_or((1920, 1080))),
video_encoder: encoder.clone(), video_encoder: encoder.clone(),
video_bitrate_kbps: options.bitrate, video_bitrate_kbps: options.bitrate,
keyframe_interval_seconds: options.keyint, keyframe_interval_seconds: options.keyint,
@@ -245,8 +255,9 @@ fn verify_encoder(engine: &Engine, config: RecordingConfig) -> Result<u64, Strin
let recording = engine let recording = engine
.start_recording(config) .start_recording(config)
.map_err(|error| error.to_string())?; .map_err(|error| error.to_string())?;
recording.poll().map_err(|error| error.to_string())?;
std::thread::sleep(Duration::from_secs(3)); std::thread::sleep(Duration::from_secs(3));
let health = recording.health(); let health = recording.poll().map_err(|error| error.to_string())?;
recording.stop(); recording.stop();
let stop = wait_for_stop(&recording, Duration::from_secs(10)); let stop = wait_for_stop(&recording, Duration::from_secs(10));
match stop { match stop {
@@ -311,6 +322,7 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
video_source: Some(video_source), video_source: Some(video_source),
system_audio: options.audio.then(capture::system_audio_source), system_audio: options.audio.then(capture::system_audio_source),
microphone: options.mic.then(capture::microphone_source), microphone: options.mic.then(capture::microphone_source),
resolution: options.resolution()?,
video_encoder: encoder, video_encoder: encoder,
video_bitrate_kbps: options.bitrate, video_bitrate_kbps: options.bitrate,
keyframe_interval_seconds: options.keyint, keyframe_interval_seconds: options.keyint,
@@ -338,7 +350,7 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
let now = Instant::now(); let now = Instant::now();
if now >= next_sample { if now >= next_sample {
next_sample += Duration::from_secs(1); next_sample += Duration::from_secs(1);
let health = recording.health(); let health = recording.poll()?;
println!( println!(
"t={} health source={}x{} pipewire={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}", "t={} health source={}x{} pipewire={:?} active={} frames={} dropped={} lagged={} muxer_bytes={} disk_bytes={}",
elapsed(), elapsed(),
@@ -361,6 +373,9 @@ fn record(options: &Options) -> Result<bool, Box<dyn std::error::Error>> {
recording.stop(); recording.stop();
} }
match recording.events().recv_timeout(Duration::from_millis(100)) { 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::OutputStarted) => println!("t={} output_started", elapsed()),
Ok(RecorderEvent::SegmentStarted { path }) => { Ok(RecorderEvent::SegmentStarted { path }) => {
println!("t={} segment_started={path}", elapsed()) println!("t={} segment_started={path}", elapsed())
+22 -12
View File
@@ -149,18 +149,28 @@ Windows status:
its last frame after the stream stops, and only logs the stream state. The 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 engine reads that log line (stable for the pinned OBS version). A more robust
follow-up is watching the PipeWire node state directly. follow-up is watching the PipeWire node state directly.
- **Closed windows on Wayland.** With Hyprland's portal, closing a captured - **Ending a session when the game closes.** With Hyprland's portal, closing a
window leaves the stream running on its last frame, with no state change or captured window leaves the stream running on its last frame, with no state
error. Capture cannot tell this apart from a static window, so sessions must change or error, so capture cannot tell it from a static window. Monitor
end from game process exit (SPEC: "Stop/finalise the session when its game capture never sees the game close at all. The session therefore ends from
exits"), which the Steam/process detection milestone provides. the game's process exiting (SPEC: "Stop/finalise the session when its game
- **Writing while waiting for capture.** The output starts before the source exits"); capture loss stays a failure signal, not the normal end:
has a picture, so a source that never arrives produces black segments. The - Linux: Steam runs each game under `reaper SteamLaunch AppId=<id>`, which
session timeline should show that time as missing footage, or the output lives exactly as long as the game (Proton included). Watch that process
should start only once capture is usable. with a pidfd (`pidfd_open` + poll) rather than polling `/proc`.
- **Canvas orientation.** The output canvas is fixed by the configured - Windows: watch the game's process handle (`OpenProcess` +
resolution, so portrait or other-aspect sources are letterboxed. Decide `WaitForSingleObject`), found from Steam's `RunningAppID` registry value.
whether the canvas should follow the captured source. 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 - **Audio capture failures are not detected yet.** When the PulseAudio server
was unreachable, recording continued with a silent track. was unreachable, recording continued with a silent track.
- **Linux runtime dependencies.** The Linux OBS build links the system's - **Linux runtime dependencies.** The Linux OBS build links the system's
+4 -2
View File
@@ -101,13 +101,15 @@ else
result "${name}" 1 result "${name}" 1
fi 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 name=portal-refused
CLIPBIN_RIG_PORTAL_OUTPUT=NO-SUCH-OUTPUT "${rig}" "${probe}" record --seconds 8 --out "${out}/${name}" \ CLIPBIN_RIG_PORTAL_OUTPUT=NO-SUCH-OUTPUT "${rig}" "${probe}" record --seconds 8 --out "${out}/${name}" \
> "${out}/${name}.log" 2>&1 > "${out}/${name}.log" 2>&1
probe_exit=$? probe_exit=$?
if [[ "${probe_exit}" != 0 ]] && ! has "${out}/${name}.log" "status=Recording" \ 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")" result "${name}" 0 "$(grep -m1 -o 'status=failed reason=.*' "${out}/${name}.log")"
else else
result "${name}" 1 result "${name}" 1