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.
This commit is contained in:
+309
-186
@@ -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!(
|
||||
|
||||
@@ -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};
|
||||
|
||||
@@ -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(¤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<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
|
||||
|
||||
Reference in New Issue
Block a user