use std::{
collections::HashMap,
env,
ffi::OsStr,
io::{self, IsTerminal, Write},
path::{Path, PathBuf},
str::FromStr,
sync::{
Arc,
atomic::{AtomicBool, Ordering},
mpsc,
},
time::{Duration, Instant},
};
use anyhow::{Context, Result, anyhow, bail, ensure};
use clap::{Parser, ValueEnum};
use gst::prelude::*;
use ocula::{
monitor,
output::{self, FinalizedRecording, OutputPaths},
pipeline::{
self, AudioMode, CaptureArea, H264Encoder, OutputFormat, OutputSize, PipelineOptions,
VideoCodec, VideoEffect, VideoSource,
},
portal, selector,
};
const MAX_VIDEO_BITRATE: u32 = i32::MAX as u32;
const DEFAULT_GIF_FPS: u32 = 15;
const ADAPTIVE_H264_MIN_BITRATE: u32 = 2_000_000;
const ADAPTIVE_H264_MAX_BITRATE: u32 = 12_000_000;
const ADAPTIVE_VP8_MIN_BITRATE: u32 = 1_500_000;
const ADAPTIVE_VP8_MAX_BITRATE: u32 = 8_000_000;
const CONTROL_CHANNEL_CAPACITY: usize = 2;
const WARNING_COUNT_CAPACITY: usize = 64;
#[derive(Debug, Parser)]
#[command(name = "oculo", author, version, about)]
struct Args {
#[arg(short, long)]
output: Option<PathBuf>,
#[arg(long, default_value = ".")]
dir: PathBuf,
#[arg(long, value_enum, default_value_t = Backend::Auto)]
backend: Backend,
#[arg(long, value_enum)]
mode: Option<RecordingMode>,
#[arg(long, default_value_t = 30, value_parser = clap::value_parser!(u32).range(1..=120))]
fps: u32,
#[arg(long, default_value_t = 0, value_parser = parse_bitrate)]
bitrate: u32,
#[arg(long, value_enum, default_value_t = CliVideoCodec::Auto)]
codec: CliVideoCodec,
#[arg(long, value_enum, default_value_t = CliH264Encoder::Auto)]
h264_encoder: CliH264Encoder,
#[arg(long, value_enum, default_value_t = CliVideoEffect::None)]
effect: CliVideoEffect,
#[arg(long, value_enum)]
format: Option<CliOutputFormat>,
#[arg(long, value_enum, default_value_t = CliAudioMode::None)]
audio: CliAudioMode,
#[arg(long, value_parser = parse_area, conflicts_with = "select")]
area: Option<CaptureArea>,
#[arg(long, hide = true, value_parser = parse_selection_screen, requires = "area")]
area_screen: Option<SelectionScreen>,
#[arg(long, value_parser = parse_monitor_index, conflicts_with_all = ["area", "select"])]
monitor: Option<usize>,
#[arg(long)]
list_monitors: bool,
#[arg(long)]
select: bool,
#[arg(long)]
no_prompt: bool,
#[arg(long, value_parser = parse_size, conflicts_with = "no_scale")]
scale: Option<OutputSize>,
#[arg(long)]
no_scale: bool,
#[arg(long)]
hide_pointer: bool,
#[arg(long, value_parser = clap::value_parser!(u64).range(1..))]
max_duration: Option<u64>,
#[arg(long, default_value_t = 15, value_parser = clap::value_parser!(u64).range(1..))]
eos_timeout: u64,
#[arg(long)]
overwrite: bool,
#[arg(long)]
multiple: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum Backend {
Auto,
Portal,
X11,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum RecordingMode {
Screen,
Window,
Area,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliAudioMode {
None,
Mic,
System,
Both,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliVideoCodec {
Auto,
H264,
Mjpeg,
Vp8,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliH264Encoder {
Auto,
Hardware,
Software,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliVideoEffect {
None,
Neon,
Heatmap,
Glitch,
Ripple,
Glass,
Crt,
Holo,
Vapor,
Fracture,
Prism,
Aurora,
Kaleido,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, ValueEnum)]
enum CliOutputFormat {
Mkv,
Mp4,
Gif,
}
impl From<CliOutputFormat> for OutputFormat {
fn from(value: CliOutputFormat) -> Self {
match value {
CliOutputFormat::Mkv => Self::Mkv,
CliOutputFormat::Mp4 => Self::Mp4,
CliOutputFormat::Gif => Self::Gif,
}
}
}
impl From<CliAudioMode> for AudioMode {
fn from(value: CliAudioMode) -> Self {
match value {
CliAudioMode::None => Self::None,
CliAudioMode::Mic => Self::Microphone,
CliAudioMode::System => Self::System,
CliAudioMode::Both => Self::Both,
}
}
}
impl From<CliVideoEffect> for VideoEffect {
fn from(value: CliVideoEffect) -> Self {
match value {
CliVideoEffect::None => Self::None,
CliVideoEffect::Neon => Self::Neon,
CliVideoEffect::Heatmap => Self::Heatmap,
CliVideoEffect::Glitch => Self::Glitch,
CliVideoEffect::Ripple => Self::Ripple,
CliVideoEffect::Glass => Self::Glass,
CliVideoEffect::Crt => Self::Crt,
CliVideoEffect::Holo => Self::Holo,
CliVideoEffect::Vapor => Self::Vapor,
CliVideoEffect::Fracture => Self::Fracture,
CliVideoEffect::Prism => Self::Prism,
CliVideoEffect::Aurora => Self::Aurora,
CliVideoEffect::Kaleido => Self::Kaleido,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum ControlMessage {
Stop,
Abort,
}
#[derive(Debug)]
struct CaptureSetup {
video_source: VideoSource,
portal_session: Option<portal::Session>,
backend: Backend,
area: Option<CaptureArea>,
source_size: Option<(i32, i32)>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
struct SelectionScreen {
width: i32,
height: i32,
}
#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
struct ExplicitPromptArgs {
audio: bool,
output_or_dir: bool,
hide_pointer: bool,
}
#[derive(Debug)]
struct RecordingRun {
clean_eos: bool,
duration: Option<gst::ClockTime>,
}
#[derive(Debug, Default)]
struct WarningCounts {
counts: HashMap<(String, String), usize>,
overflow_reported: bool,
}
impl WarningCounts {
fn new() -> Self {
Self {
counts: HashMap::with_capacity(WARNING_COUNT_CAPACITY),
overflow_reported: false,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum WarningOccurrence {
First,
Second,
Suppressed,
CapacityReached,
}
fn main() {
if let Err(err) = real_main() {
eprintln!("error: {err:#}");
std::process::exit(1);
}
}
fn real_main() -> Result<()> {
let mut args = Args::parse();
let explicit_fps = explicit_fps_arg();
let mut selection_screen = args.area_screen;
if args.list_monitors {
print_x11_monitors()?;
return Ok(());
}
maybe_prompt_recording_mode(&mut args)?;
normalize_recording_request(&mut args)?;
let output_format = selected_output_format(&args)?;
validate_format_options(&args, output_format)?;
output::validate_request(args.output.as_deref(), &args.dir, output_format)?;
resolve_monitor_area(&mut args)?;
gst::init().context("failed to initialize GStreamer")?;
let codec = choose_codec(args.codec, args.h264_encoder, output_format)?;
let effect = VideoEffect::from(args.effect);
let audio = args.audio.into();
require_common_plugins(audio, codec, effect, output_format)?;
pipeline::preflight_audio(audio)?;
if args.select {
let selection = selector::select_area_once_with_effect(effect)?;
args.area = Some(selection.area);
selection_screen = Some(SelectionScreen {
width: selection.screen_width,
height: selection.screen_height,
});
}
let CaptureSetup {
video_source,
mut portal_session,
backend,
area,
source_size,
} = setup_capture(&args, selection_screen)?;
let area = normalize_selected_area_for_codec(area, codec)?;
let source_size = area.map(|area| (area.width, area.height)).or(source_size);
if let Some(area) = area {
eprintln!(
"selection area: {},{},{},{}",
area.x, area.y, area.width, area.height
);
}
let scale = match selected_scale(
recording_mode(&args),
source_size,
args.scale,
args.no_scale,
) {
Ok(scale) => scale,
Err(err) => {
close_portal_session(portal_session.take());
return Err(err);
}
};
let fps = selected_fps(output_format, args.fps, explicit_fps);
if output_format == OutputFormat::Gif && !explicit_fps && args.fps != fps {
eprintln!("GIF output defaults to {fps} fps; pass --fps to override");
}
let bitrate = selected_bitrate(codec, args.bitrate, source_size, scale, fps);
let control_rx = match install_signal_handler() {
Ok(control_rx) => control_rx,
Err(err) => {
close_portal_session(portal_session.take());
return Err(err);
}
};
let output_paths = match output::prepare(
args.output.as_deref(),
&args.dir,
args.overwrite,
output_format,
) {
Ok(output_paths) => output_paths,
Err(err) => {
close_portal_session(portal_session.take());
return Err(err);
}
};
let pipeline = match pipeline::build(PipelineOptions {
video_source,
temp_path: output_paths.temp_path.clone(),
output_format,
fps,
bitrate,
codec,
effect,
audio,
area,
scale,
}) {
Ok(pipeline) => pipeline,
Err(err) => {
close_portal_session(portal_session.take());
if let Err(cleanup_err) = output::discard(output_paths) {
eprintln!("warning: failed to discard reserved output: {cleanup_err:#}");
}
return Err(err);
}
};
eprintln!(
"recording with {:?} backend, {} codec, {} fps -> {}",
backend,
codec,
fps,
output_paths.final_path.display()
);
if let Some(scale) = scale {
eprintln!("output scale: {}x{}", scale.width, scale.height);
}
if args.bitrate == 0 && bitrate > 0 {
eprintln!("default bitrate: {} Mbps", bitrate / 1_000_000);
}
if effect != VideoEffect::None {
eprintln!("video effect: {effect}");
}
eprintln!("press Ctrl-C to stop; press Ctrl-C again to abort");
let run_result = run_pipeline(
&pipeline,
control_rx,
args.max_duration.map(Duration::from_secs),
Duration::from_secs(args.eos_timeout),
);
let stop_result = stop_pipeline(&pipeline);
close_portal_session(portal_session.take());
let run = match (run_result, stop_result) {
(Ok(run), Ok(())) => run,
(Err(err), Ok(())) => {
match save_partial(output_paths) {
Ok(saved) => print_partial_recording(saved),
Err(save_err) => {
eprintln!("warning: failed to save partial recording: {save_err:#}")
}
}
return Err(err);
}
(Ok(_), Err(err)) => {
print_temporary_recording_left(&output_paths);
return Err(err)
.context("pipeline did not fully stop, so the recording was not finalized");
}
(Err(err), Err(stop_err)) => {
eprintln!("warning: pipeline also failed to stop: {stop_err:#}");
print_temporary_recording_left(&output_paths);
return Err(err).context(
"recording failed and pipeline did not fully stop; temporary file was left in place",
);
}
};
if run.clean_eos {
let saved = output::finalize(output_paths)?;
match run.duration {
Some(duration) => eprintln!(
"saved recording: {} ({} bytes, {})",
saved.path.display(),
saved.bytes,
format_duration(duration)
),
None => eprintln!(
"saved recording: {} ({} bytes)",
saved.path.display(),
saved.bytes
),
}
} else {
print_partial_recording(save_partial(output_paths)?);
}
Ok(())
}
fn save_partial(output_paths: OutputPaths) -> Result<Option<FinalizedRecording>> {
output::finalize_nonempty(output_paths)
.context("non-empty temporary recording could not be promoted to the final output")
}
fn print_partial_recording(saved: Option<FinalizedRecording>) {
match saved {
Some(saved) => eprintln!(
"saved partial recording: {} ({} bytes)",
saved.path.display(),
saved.bytes
),
None => eprintln!("discarded empty partial recording"),
}
}
fn print_temporary_recording_left(output_paths: &OutputPaths) {
eprintln!(
"left temporary recording for manual recovery: {}",
output_paths.temp_path.display()
);
}
fn maybe_prompt_recording_mode(args: &mut Args) -> Result<()> {
if !should_prompt_recording_mode(args, io::stdin().is_terminal()) {
return Ok(());
}
eprintln!("What do you want to record?");
eprintln!(" 1) Screen or monitor");
eprintln!(" 2) Window");
eprintln!(" 3) Area");
match prompt_choice("Choose recording mode", 1, 3)? {
1 => {
args.mode = Some(RecordingMode::Screen);
maybe_prompt_x11_monitor(args)?;
}
2 => args.mode = Some(RecordingMode::Window),
3 => {
args.mode = Some(RecordingMode::Area);
args.select = true;
}
_ => unreachable!(),
}
maybe_prompt_recording_options(args, explicit_prompt_args())?;
Ok(())
}
fn maybe_prompt_recording_options(args: &mut Args, explicit: ExplicitPromptArgs) -> Result<()> {
if !explicit.audio {
eprintln!("Audio capture?");
eprintln!(" 1) None");
eprintln!(" 2) Microphone");
eprintln!(" 3) System audio");
eprintln!(" 4) Microphone + system audio");
args.audio = match prompt_choice("Choose audio", 1, 4)? {
1 => CliAudioMode::None,
2 => CliAudioMode::Mic,
3 => CliAudioMode::System,
4 => CliAudioMode::Both,
_ => unreachable!(),
};
}
if !explicit.hide_pointer {
eprintln!("Mouse pointer?");
eprintln!(" 1) Show pointer");
eprintln!(" 2) Hide pointer");
args.hide_pointer = match prompt_choice("Choose pointer mode", 1, 2)? {
1 => false,
2 => true,
_ => unreachable!(),
};
}
if !explicit.output_or_dir
&& let Some(output) = prompt_optional_path("Output file")?
{
args.output = Some(output);
}
Ok(())
}
fn maybe_prompt_x11_monitor(args: &mut Args) -> Result<()> {
if !should_offer_x11_monitor_prompt(args) {
return Ok(());
}
let monitors = monitor::list_x11_monitors()?;
if monitors.len() <= 1 {
return Ok(());
}
eprintln!("Available X11 monitors:");
eprintln!(" 0) All monitors");
for monitor in &monitors {
eprintln!(" {}", monitor::format_monitor(monitor));
}
let choice = prompt_choice("Choose monitor", 0, monitors.len())?;
if choice != 0 {
monitor::validate_monitor_index(choice, monitors.len())?;
args.monitor = Some(choice);
}
Ok(())
}
fn should_prompt_recording_mode(args: &Args, stdin_is_terminal: bool) -> bool {
stdin_is_terminal
&& !args.no_prompt
&& !args.list_monitors
&& args.mode.is_none()
&& args.area.is_none()
&& args.monitor.is_none()
&& !args.select
}
fn should_offer_x11_monitor_prompt(args: &Args) -> bool {
should_offer_x11_monitor_prompt_for(
args.backend,
env::var_os("DISPLAY").is_some(),
env::var_os("WAYLAND_DISPLAY").is_some(),
)
}
fn should_offer_x11_monitor_prompt_for(
backend: Backend,
has_display: bool,
has_wayland: bool,
) -> bool {
match backend {
Backend::X11 => has_display,
Backend::Auto => has_display && !has_wayland,
Backend::Portal => false,
}
}
fn prompt_choice(prompt: &str, min: usize, max: usize) -> Result<usize> {
loop {
print!("{prompt} [{min}]: ");
io::stdout().flush().context("failed to flush prompt")?;
let mut line = String::new();
io::stdin()
.read_line(&mut line)
.context("failed to read prompt response")?;
let line = line.trim();
if line.is_empty() {
return Ok(min);
}
match line.parse::<usize>() {
Ok(choice) if (min..=max).contains(&choice) => return Ok(choice),
_ => eprintln!("enter a number from {min} to {max}"),
}
}
}
fn prompt_optional_path(prompt: &str) -> Result<Option<PathBuf>> {
print!("{prompt} [auto]: ");
io::stdout().flush().context("failed to flush prompt")?;
let mut line = String::new();
io::stdin()
.read_line(&mut line)
.context("failed to read prompt response")?;
let home = env::var_os("HOME").map(PathBuf::from);
Ok(prompt_path_from_input(&line, home.as_deref()))
}
fn prompt_path_from_input(input: &str, home: Option<&Path>) -> Option<PathBuf> {
let path = strip_matching_quotes(input.trim()).trim();
if path.is_empty() {
return None;
}
Some(expand_prompt_path(path, home))
}
fn strip_matching_quotes(input: &str) -> &str {
if input.len() < 2 {
return input;
}
let quoted = (input.starts_with('"') && input.ends_with('"'))
|| (input.starts_with('\'') && input.ends_with('\''));
if quoted {
&input[1..input.len() - 1]
} else {
input
}
}
fn expand_prompt_path(path: &str, home: Option<&Path>) -> PathBuf {
match (path, home) {
("~", Some(home)) => home.to_path_buf(),
(path, Some(home)) if path.starts_with("~/") => home.join(&path[2..]),
_ => PathBuf::from(path),
}
}
fn explicit_prompt_args() -> ExplicitPromptArgs {
explicit_prompt_args_from(env::args_os().skip(1))
}
fn explicit_fps_arg() -> bool {
explicit_fps_arg_from(env::args_os().skip(1))
}
fn explicit_fps_arg_from<I, S>(args: I) -> bool
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
args.into_iter()
.any(|arg| is_long_option(&arg.as_ref().to_string_lossy(), "--fps"))
}
fn explicit_prompt_args_from<I, S>(args: I) -> ExplicitPromptArgs
where
I: IntoIterator<Item = S>,
S: AsRef<OsStr>,
{
let mut explicit = ExplicitPromptArgs::default();
for arg in args {
let arg = arg.as_ref().to_string_lossy();
if is_long_option(&arg, "--audio") {
explicit.audio = true;
}
if is_long_option(&arg, "--output")
|| is_short_output_option(&arg)
|| is_long_option(&arg, "--dir")
{
explicit.output_or_dir = true;
}
if arg == "--hide-pointer" {
explicit.hide_pointer = true;
}
}
explicit
}
fn is_long_option(raw: &str, name: &str) -> bool {
raw == name
|| raw
.strip_prefix(name)
.is_some_and(|suffix| suffix.starts_with('='))
}
fn is_short_output_option(raw: &str) -> bool {
raw == "-o"
|| raw
.strip_prefix("-o")
.is_some_and(|suffix| !suffix.is_empty())
}
fn normalize_recording_request(args: &mut Args) -> Result<()> {
validate_recording_request(args)?;
if args.monitor.is_some() && args.backend == Backend::Portal {
bail!(
"--monitor is only available with the X11 backend; omit it to use the portal chooser"
);
}
if args.monitor.is_some() && args.backend == Backend::Auto {
args.backend = Backend::X11;
}
if args.multiple && args.backend == Backend::Auto {
args.backend = Backend::Portal;
}
if matches!(args.mode, Some(RecordingMode::Area)) && args.area.is_none() && !args.select {
args.select = true;
}
let has_wayland = env::var_os("WAYLAND_DISPLAY").is_some();
let x11_available = args.select && has_wayland && monitor::x11_available();
normalize_selection_backend(args, has_wayland, x11_available)?;
Ok(())
}
fn normalize_selection_backend(
args: &mut Args,
has_wayland: bool,
x11_available: bool,
) -> Result<()> {
validate_selection_environment(args.select, has_wayland, x11_available)
}
fn validate_selection_environment(
select: bool,
has_wayland: bool,
x11_available: bool,
) -> Result<()> {
if select && has_wayland && !x11_available {
bail!(
"--select needs a reachable X11/XWayland DISPLAY for the crop overlay; pass --area X,Y,WIDTH,HEIGHT instead"
);
}
Ok(())
}
fn resolve_monitor_area(args: &mut Args) -> Result<()> {
if let Some(index) = args.monitor {
args.area = Some(monitor::x11_monitor_area(index)?);
}
Ok(())
}
fn validate_recording_request(args: &Args) -> Result<()> {
if args.mode == Some(RecordingMode::Window)
&& (args.area.is_some() || args.select || args.monitor.is_some())
{
bail!("--mode window cannot be combined with --area, --select, or --monitor");
}
if args.mode == Some(RecordingMode::Window) && args.backend == Backend::X11 {
bail!("--mode window requires the portal backend");
}
if args.mode == Some(RecordingMode::Screen) && (args.area.is_some() || args.select) {
bail!("--mode screen cannot be combined with --area or --select");
}
if args.mode == Some(RecordingMode::Area) && args.monitor.is_some() {
bail!("--mode area cannot be combined with --monitor");
}
if args.multiple && args.backend == Backend::X11 {
bail!("--multiple requires the portal backend");
}
if args.multiple && args.monitor.is_some() {
bail!("--multiple cannot be combined with --monitor");
}
Ok(())
}
fn recording_mode(args: &Args) -> RecordingMode {
if args.monitor.is_some() {
RecordingMode::Screen
} else if args.area.is_some() || args.select {
RecordingMode::Area
} else {
args.mode.unwrap_or(RecordingMode::Screen)
}
}
fn normalize_selected_area_for_codec(
area: Option<CaptureArea>,
codec: VideoCodec,
) -> Result<Option<CaptureArea>> {
if !codec_requires_even_dimensions(codec) {
return Ok(area);
}
let Some(area) = area else {
return Ok(None);
};
even_sized_area(area).map(Some)
}
fn codec_requires_even_dimensions(codec: VideoCodec) -> bool {
matches!(codec, VideoCodec::H264(_) | VideoCodec::Vp8)
}
fn even_sized_area(area: CaptureArea) -> Result<CaptureArea> {
let width = area.width - area.width.rem_euclid(2);
let height = area.height - area.height.rem_euclid(2);
ensure!(
width > 0 && height > 0,
"selected area must be at least 2x2 for the selected video codec"
);
CaptureArea::new(area.x, area.y, width, height)
}
fn portal_source_type(mode: RecordingMode) -> portal::SourceType {
match mode {
RecordingMode::Screen | RecordingMode::Area => portal::SourceType::MONITOR,
RecordingMode::Window => portal::SourceType::WINDOW,
}
}
fn print_x11_monitors() -> Result<()> {
for monitor in monitor::list_x11_monitors()? {
println!("{}", monitor::format_monitor(&monitor));
}
Ok(())
}
fn setup_capture(args: &Args, selection_screen: Option<SelectionScreen>) -> Result<CaptureSetup> {
let mut last_error = None;
for backend in backend_order(args) {
match setup_backend(backend, args, selection_screen) {
Ok(capture) => return Ok(capture),
Err(err) if is_user_cancel(&err) => return Err(err),
Err(err) => {
eprintln!("{backend:?} backend unavailable: {err:#}");
last_error = Some(err);
}
}
}
Err(last_error.unwrap_or_else(|| anyhow!("no capture backend could be selected")))
}
fn backend_order(args: &Args) -> Vec<Backend> {
let has_display = if args.backend == Backend::Auto {
monitor::x11_available()
} else {
env::var_os("DISPLAY").is_some()
};
backend_order_for(
args.backend,
recording_mode(args),
env::var_os("WAYLAND_DISPLAY").is_some(),
has_display,
)
}
fn backend_order_for(
backend: Backend,
mode: RecordingMode,
has_wayland: bool,
has_display: bool,
) -> Vec<Backend> {
match backend {
Backend::Portal => vec![Backend::Portal],
Backend::X11 => vec![Backend::X11],
Backend::Auto if mode == RecordingMode::Window => vec![Backend::Portal],
Backend::Auto if has_wayland => vec![Backend::Portal],
Backend::Auto if mode == RecordingMode::Area && has_display => {
vec![Backend::X11, Backend::Portal]
}
Backend::Auto if has_display => vec![Backend::X11, Backend::Portal],
Backend::Auto => vec![Backend::Portal],
}
}
fn setup_backend(
backend: Backend,
args: &Args,
selection_screen: Option<SelectionScreen>,
) -> Result<CaptureSetup> {
match backend {
Backend::Auto => unreachable!("auto is expanded before setup"),
Backend::Portal => {
require_portal_plugins(args.multiple)?;
let context = glib::MainContext::default();
let capture = context.block_on(portal::start_screencast(portal::StartOptions {
cursor_mode: if args.hide_pointer {
portal::CursorMode::HIDDEN
} else {
portal::CursorMode::EMBEDDED
},
source_type: portal_source_type(recording_mode(args)),
multiple: args.multiple,
}))?;
if let Err(err) = pipeline::validate_portal_streams(&capture.streams) {
close_unusable_portal_session(&context, capture.session);
return Err(err).context("portal capture returned unsupported stream geometry");
}
if capture.streams.len() > 1
&& let Err(err) = require_element("compositor")
{
close_unusable_portal_session(&context, capture.session);
return Err(err).context("portal capture returned multiple streams");
}
let area = match (args.area, selection_screen) {
(Some(area), Some(screen)) => {
match pipeline::scale_area_to_portal_streams(
area,
screen.width,
screen.height,
&capture.streams,
) {
Ok(area) => Some(area),
Err(err) => {
close_unusable_portal_session(&context, capture.session);
return Err(err).context("portal capture cannot scale selected area");
}
}
}
(area, _) => area,
};
if let Some(area) = area
&& let Err(err) = pipeline::validate_portal_area(area, &capture.streams)
{
close_unusable_portal_session(&context, capture.session);
return Err(err).context("portal capture cannot use selected area");
}
let source_size = area
.map(|area| (area.width, area.height))
.or_else(|| pipeline::portal_stream_size(&capture.streams));
Ok(CaptureSetup {
video_source: VideoSource::Portal {
fd: capture.fd,
streams: capture.streams,
},
portal_session: Some(capture.session),
backend,
area,
source_size,
})
}
Backend::X11 => {
if recording_mode(args) == RecordingMode::Window {
bail!("window recording requires the portal backend");
}
require_element("ximagesrc")?;
if let Some(area) = args.area {
monitor::validate_x11_capture_area(area)?;
}
let source_size = match args.area {
Some(area) => Some((area.width, area.height)),
None => Some(monitor::x11_screen_size()?),
};
Ok(CaptureSetup {
video_source: VideoSource::X11 {
show_pointer: !args.hide_pointer,
},
portal_session: None,
backend,
area: args.area,
source_size,
})
}
}
}
fn close_portal_session(session: Option<portal::Session>) {
if let Some(session) = session {
let context = glib::MainContext::default();
if let Err(err) = context.block_on(session.close()) {
eprintln!("warning: failed to close portal session: {err:#}");
}
}
}
fn close_unusable_portal_session(context: &glib::MainContext, session: portal::Session) {
if let Err(close_err) = context.block_on(session.close()) {
eprintln!("warning: failed to close unusable portal session: {close_err:#}");
}
}
fn install_signal_handler() -> Result<mpsc::Receiver<ControlMessage>> {
let (tx, rx) = mpsc::sync_channel(CONTROL_CHANNEL_CAPACITY);
let seen_stop = Arc::new(AtomicBool::new(false));
let seen_stop_for_handler = Arc::clone(&seen_stop);
ctrlc::set_handler(move || {
let message = if seen_stop_for_handler.swap(true, Ordering::SeqCst) {
ControlMessage::Abort
} else {
ControlMessage::Stop
};
let _ = tx.try_send(message);
})
.context("failed to install Ctrl-C/SIGTERM handler")?;
Ok(rx)
}
fn run_pipeline(
pipeline: &gst::Pipeline,
control_rx: mpsc::Receiver<ControlMessage>,
max_duration: Option<Duration>,
eos_timeout: Duration,
) -> Result<RecordingRun> {
let bus = pipeline.bus().context("pipeline has no bus")?;
pipeline
.set_state(gst::State::Playing)
.context("failed to start recording pipeline")?;
wait_until_playing(pipeline, &bus, &control_rx, Duration::from_secs(15))
.context("pipeline did not reach Playing state")?;
let started_at = Instant::now();
let mut stopping_since = None;
let mut warning_counts = WarningCounts::new();
loop {
while let Ok(message) = control_rx.try_recv() {
match message {
ControlMessage::Stop if stopping_since.is_none() => {
eprintln!("stopping cleanly, waiting for muxer finalization...");
request_eos(pipeline)?;
stopping_since = Some(Instant::now());
}
ControlMessage::Stop => {}
ControlMessage::Abort => {
bail!("recording aborted before clean finalization completed");
}
}
}
if stopping_since.is_none()
&& max_duration.is_some_and(|duration| started_at.elapsed() >= duration)
{
eprintln!("max duration reached, stopping cleanly...");
request_eos(pipeline)?;
stopping_since = Some(Instant::now());
}
if let Some(stopped_at) = stopping_since
&& stopped_at.elapsed() >= eos_timeout
{
return Ok(RecordingRun {
clean_eos: false,
duration: pipeline.query_position::<gst::ClockTime>(),
});
}
let Some(message) = bus.timed_pop(gst::ClockTime::from_mseconds(200)) else {
continue;
};
use gst::MessageView;
match message.view() {
MessageView::Eos(..) => {
return Ok(RecordingRun {
clean_eos: true,
duration: pipeline.query_position::<gst::ClockTime>(),
});
}
MessageView::Error(err) => {
let source = err
.src()
.map(|src| src.path_string())
.unwrap_or_else(|| "unknown".into());
let debug = err.debug().unwrap_or_else(|| "no debug info".into());
bail!("GStreamer error from {source}: {}; {debug}", err.error());
}
MessageView::Warning(warning) => {
print_gstreamer_warning(warning, &mut warning_counts);
}
_ => {}
}
}
}
fn wait_until_playing(
pipeline: &gst::Pipeline,
bus: &gst::Bus,
control_rx: &mpsc::Receiver<ControlMessage>,
timeout: Duration,
) -> Result<()> {
let started_at = Instant::now();
let mut warning_counts = WarningCounts::new();
loop {
if let Ok(message) = control_rx.try_recv() {
match message {
ControlMessage::Stop => bail!("recording stopped before pipeline reached Playing"),
ControlMessage::Abort => {
bail!("recording aborted before pipeline reached Playing")
}
}
}
let (_, current, pending) = pipeline.state(Some(gst::ClockTime::from_mseconds(0)));
if current == gst::State::Playing {
return Ok(());
}
if started_at.elapsed() >= timeout {
bail!(
"pipeline reached {:?}, expected {:?}; pending {:?}",
current,
gst::State::Playing,
pending
);
}
let Some(message) = bus.timed_pop(gst::ClockTime::from_mseconds(100)) else {
continue;
};
use gst::MessageView;
match message.view() {
MessageView::Error(err) => {
let source = err
.src()
.map(|src| src.path_string())
.unwrap_or_else(|| "unknown".into());
let debug = err.debug().unwrap_or_else(|| "no debug info".into());
bail!("GStreamer error from {source}: {}; {debug}", err.error());
}
MessageView::Warning(warning) => {
print_gstreamer_warning(warning, &mut warning_counts);
}
MessageView::Eos(..) => bail!("pipeline reached EOS before Playing"),
_ => {}
}
}
}
fn print_gstreamer_warning(warning: &gst::message::Warning, counts: &mut WarningCounts) {
let source = warning
.src()
.map(|src| src.path_string())
.unwrap_or_else(|| "unknown".into());
let error = warning.error().to_string();
match warning_occurrence(counts, &source, &error) {
WarningOccurrence::First => {
let debug = warning.debug().map(|debug| format!("; {debug}"));
eprintln!(
"warning from {source}: {error}{}",
debug.unwrap_or_default()
);
}
WarningOccurrence::Second => eprintln!(
"warning from {source}: {error} (repeated; suppressing further warnings with same source and message)"
),
WarningOccurrence::CapacityReached => {
eprintln!("warning limit reached; suppressing additional distinct GStreamer warnings")
}
WarningOccurrence::Suppressed => {}
}
}
fn warning_occurrence(counts: &mut WarningCounts, source: &str, error: &str) -> WarningOccurrence {
let key = (source.to_owned(), error.to_owned());
if let Some(count) = counts.counts.get_mut(&key) {
*count = count.saturating_add(1);
return if *count == 2 {
WarningOccurrence::Second
} else {
WarningOccurrence::Suppressed
};
}
if counts.counts.len() >= WARNING_COUNT_CAPACITY {
if counts.overflow_reported {
WarningOccurrence::Suppressed
} else {
counts.overflow_reported = true;
WarningOccurrence::CapacityReached
}
} else {
counts.counts.insert(key, 1);
WarningOccurrence::First
}
}
fn request_eos(pipeline: &gst::Pipeline) -> Result<()> {
if pipeline.send_event(gst::event::Eos::new()) {
Ok(())
} else {
bail!("pipeline refused EOS event")
}
}
fn stop_pipeline(pipeline: &gst::Pipeline) -> Result<()> {
pipeline
.set_state(gst::State::Null)
.context("failed to request pipeline stop")?;
let (state_result, current, pending) = pipeline.state(Some(gst::ClockTime::from_seconds(15)));
state_result.with_context(|| {
format!(
"pipeline stop state is {:?} with pending state {:?}",
current, pending
)
})?;
if current == gst::State::Null {
return Ok(());
}
bail!(
"pipeline reached {:?}, expected {:?}; pending {:?}",
current,
gst::State::Null,
pending
)
}
fn require_common_plugins(
audio: AudioMode,
codec: VideoCodec,
effect: VideoEffect,
output_format: OutputFormat,
) -> Result<()> {
for element in [
"videorate",
"videoscale",
"videoconvert",
"videocrop",
"queue",
"filesink",
] {
require_element(element)?;
}
if let Some(element) = output_format.muxer_element() {
require_element(element)?;
}
for element in codec.required_elements() {
require_element(element)?;
}
for element in effect.required_elements() {
require_element(element)?;
}
if audio != AudioMode::None {
for element in [
"pulsesrc",
"audiomixer",
"audiorate",
"audioconvert",
"audioresample",
] {
require_element(element)?;
}
match output_format {
OutputFormat::Mkv => require_element("opusenc")?,
OutputFormat::Mp4 => {
pipeline::aac_encoder_element()
.with_context(|| "MP4 audio requires an AAC encoder such as fdkaacenc")?;
}
OutputFormat::Gif => bail!("GIF output does not support audio"),
}
}
Ok(())
}
fn require_portal_plugins(multiple: bool) -> Result<()> {
for element in portal_required_elements(multiple) {
require_element(element)?;
}
Ok(())
}
fn portal_required_elements(multiple: bool) -> &'static [&'static str] {
if multiple {
&["pipewiresrc", "videoflip", "compositor"]
} else {
&["pipewiresrc", "videoflip"]
}
}
fn require_element(name: &str) -> Result<()> {
gst::ElementFactory::find(name)
.map(|_| ())
.with_context(|| format!("missing GStreamer element `{name}`"))
}
fn has_element(name: &str) -> bool {
gst::ElementFactory::find(name).is_some()
}
fn selected_output_format(args: &Args) -> Result<OutputFormat> {
let inferred = args
.output
.as_deref()
.and_then(|path| path.extension())
.and_then(|extension| extension.to_str())
.and_then(|extension| {
if extension.eq_ignore_ascii_case("mp4") {
Some(OutputFormat::Mp4)
} else if extension.eq_ignore_ascii_case("mkv") {
Some(OutputFormat::Mkv)
} else if extension.eq_ignore_ascii_case("gif") {
Some(OutputFormat::Gif)
} else {
None
}
});
let selected = args
.format
.map(OutputFormat::from)
.or(inferred)
.unwrap_or(OutputFormat::Mp4);
if let (Some(explicit), Some(inferred)) = (args.format.map(OutputFormat::from), inferred) {
ensure!(
explicit == inferred,
"--format does not match --output extension .{}",
inferred.extension()
);
}
Ok(selected)
}
fn validate_format_options(args: &Args, output_format: OutputFormat) -> Result<()> {
if output_format == OutputFormat::Gif {
ensure!(
args.audio == CliAudioMode::None,
"GIF output does not support audio; choose MKV or MP4 for audio"
);
ensure!(
!matches!(
args.codec,
CliVideoCodec::H264 | CliVideoCodec::Mjpeg | CliVideoCodec::Vp8
),
"GIF output requires --codec auto"
);
}
Ok(())
}
fn choose_codec(
codec: CliVideoCodec,
h264_encoder: CliH264Encoder,
output_format: OutputFormat,
) -> Result<VideoCodec> {
if output_format == OutputFormat::Gif {
return if has_element("gifenc") {
Ok(VideoCodec::Gif)
} else {
bail!("GIF output requires GStreamer element `gifenc`")
};
}
if output_format == OutputFormat::Mp4 {
match codec {
CliVideoCodec::Auto | CliVideoCodec::H264 => {
return Ok(VideoCodec::H264(select_h264_encoder(h264_encoder)?));
}
CliVideoCodec::Mjpeg | CliVideoCodec::Vp8 => bail!("MP4 output requires H.264 video"),
}
}
match codec {
CliVideoCodec::H264 => Ok(VideoCodec::H264(select_h264_encoder(h264_encoder)?)),
CliVideoCodec::Mjpeg => Ok(VideoCodec::Mjpeg),
CliVideoCodec::Vp8 => Ok(VideoCodec::Vp8),
CliVideoCodec::Auto if has_element("jpegenc") => Ok(VideoCodec::Mjpeg),
CliVideoCodec::Auto if h264_available(h264_encoder) => {
Ok(VideoCodec::H264(select_h264_encoder(h264_encoder)?))
}
CliVideoCodec::Auto if has_element("vp8enc") => Ok(VideoCodec::Vp8),
CliVideoCodec::Auto => bail!("no supported video encoder found"),
}
}
fn h264_available(preference: CliH264Encoder) -> bool {
select_h264_encoder(preference).is_ok()
}
fn select_h264_encoder(preference: CliH264Encoder) -> Result<H264Encoder> {
match preference {
CliH264Encoder::Auto => pipeline::hardware_h264_encoder()
.or_else(|| has_element("openh264enc").then_some(H264Encoder::OpenH264))
.filter(|_| has_element("h264parse"))
.context(
"H.264 output requires a hardware H.264 encoder or openh264enc plus h264parse",
),
CliH264Encoder::Hardware => pipeline::hardware_h264_encoder()
.filter(|_| has_element("h264parse"))
.context("hardware H.264 requested but no supported hardware encoder was found"),
CliH264Encoder::Software => {
ensure!(
has_element("openh264enc") && has_element("h264parse"),
"software H.264 requires openh264enc and h264parse"
);
Ok(H264Encoder::OpenH264)
}
}
}
fn parse_area(raw: &str) -> Result<CaptureArea, String> {
CaptureArea::from_str(raw).map_err(|err| err.to_string())
}
fn parse_bitrate(raw: &str) -> Result<u32, String> {
let bitrate = raw
.parse::<u32>()
.map_err(|err| format!("invalid bitrate: {err}"))?;
if bitrate > MAX_VIDEO_BITRATE {
return Err(format!("bitrate must be <= {MAX_VIDEO_BITRATE}"));
}
Ok(bitrate)
}
fn parse_monitor_index(raw: &str) -> Result<usize, String> {
let index = raw
.parse::<usize>()
.map_err(|err| format!("invalid monitor index: {err}"))?;
if index == 0 {
return Err("monitor index must be >= 1".to_string());
}
Ok(index)
}
fn parse_size(raw: &str) -> Result<OutputSize, String> {
OutputSize::from_str(raw).map_err(|err| err.to_string())
}
fn parse_selection_screen(raw: &str) -> Result<SelectionScreen, String> {
let Some((width, height)) = raw.split_once('x') else {
return Err("expected WIDTHxHEIGHT".to_string());
};
let width = width
.parse::<i32>()
.map_err(|err| format!("invalid selector screen width: {err}"))?;
let height = height
.parse::<i32>()
.map_err(|err| format!("invalid selector screen height: {err}"))?;
if width <= 0 || height <= 0 {
return Err("selector screen dimensions must be > 0".to_string());
}
Ok(SelectionScreen { width, height })
}
fn selected_scale(
mode: RecordingMode,
source_size: Option<(i32, i32)>,
requested: Option<OutputSize>,
no_scale: bool,
) -> Result<Option<OutputSize>> {
if no_scale {
return Ok(None);
}
if requested.is_some() {
return Ok(requested);
}
if mode != RecordingMode::Screen {
return Ok(None);
}
let default = OutputSize::new(1920, 1080)?;
if source_size.is_some_and(|(width, height)| width <= default.width && height <= default.height)
{
return Ok(None);
}
Ok(Some(default))
}
fn selected_fps(format: OutputFormat, requested_fps: u32, explicit_fps: bool) -> u32 {
if format == OutputFormat::Gif && !explicit_fps {
DEFAULT_GIF_FPS
} else {
requested_fps
}
}
fn selected_bitrate(
codec: VideoCodec,
requested_bitrate: u32,
source_size: Option<(i32, i32)>,
scale: Option<OutputSize>,
fps: u32,
) -> u32 {
if requested_bitrate != 0 {
return requested_bitrate;
}
let Some((width, height)) = output_dimensions(source_size, scale) else {
return 0;
};
match codec {
VideoCodec::H264(_) => adaptive_bitrate(
width,
height,
fps,
14,
ADAPTIVE_H264_MIN_BITRATE,
ADAPTIVE_H264_MAX_BITRATE,
),
VideoCodec::Vp8 => adaptive_bitrate(
width,
height,
fps,
10,
ADAPTIVE_VP8_MIN_BITRATE,
ADAPTIVE_VP8_MAX_BITRATE,
),
VideoCodec::Mjpeg | VideoCodec::Gif => 0,
}
}
fn output_dimensions(
source_size: Option<(i32, i32)>,
scale: Option<OutputSize>,
) -> Option<(i32, i32)> {
if let Some(scale) = scale {
return Some((scale.width, scale.height));
}
source_size
}
fn adaptive_bitrate(
width: i32,
height: i32,
fps: u32,
centibits_per_pixel_frame: u64,
min: u32,
max: u32,
) -> u32 {
if width <= 0 || height <= 0 || fps == 0 {
return min;
}
let pixels = u64::try_from(width)
.unwrap_or(u64::MAX)
.saturating_mul(u64::try_from(height).unwrap_or(u64::MAX));
let bitrate = pixels
.saturating_mul(u64::from(fps))
.saturating_mul(centibits_per_pixel_frame)
/ 100;
bitrate.clamp(u64::from(min), u64::from(max)) as u32
}
fn is_user_cancel(err: &anyhow::Error) -> bool {
err.chain()
.any(|cause| cause.to_string().contains("cancelled by the user"))
}
fn format_duration(duration: gst::ClockTime) -> String {
let seconds = duration.seconds();
let mins = seconds / 60;
let secs = seconds % 60;
format!("{mins:02}:{secs:02}")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn auto_backend_prefers_portal_on_wayland_without_selection() {
assert_eq!(
backend_order_for(Backend::Auto, RecordingMode::Screen, true, true),
vec![Backend::Portal]
);
}
#[test]
fn auto_backend_does_not_fall_back_to_x11_on_wayland() {
assert_eq!(
backend_order_for(Backend::Auto, RecordingMode::Area, true, true),
vec![Backend::Portal]
);
}
#[test]
fn auto_backend_prefers_x11_for_selection_without_wayland() {
assert_eq!(
backend_order_for(Backend::Auto, RecordingMode::Area, false, true),
vec![Backend::X11, Backend::Portal]
);
}
#[test]
fn auto_backend_uses_portal_for_window_recording() {
assert_eq!(
backend_order_for(Backend::Auto, RecordingMode::Window, false, true),
vec![Backend::Portal]
);
}
#[test]
fn explicit_backend_is_preserved_for_selection() {
assert_eq!(
backend_order_for(Backend::Portal, RecordingMode::Area, true, true),
vec![Backend::Portal]
);
assert_eq!(
backend_order_for(Backend::X11, RecordingMode::Area, true, true),
vec![Backend::X11]
);
}
#[test]
fn recording_mode_infers_area_from_area_or_select() {
let mut args = Args::try_parse_from(["oculo"]).unwrap();
assert_eq!(recording_mode(&args), RecordingMode::Screen);
args.area = Some(CaptureArea::new(1, 2, 3, 4).unwrap());
assert_eq!(recording_mode(&args), RecordingMode::Area);
args.area = None;
args.select = true;
assert_eq!(recording_mode(&args), RecordingMode::Area);
}
#[test]
fn recording_mode_keeps_monitor_capture_as_screen() {
let mut args = Args::try_parse_from(["oculo", "--monitor", "1"]).unwrap();
args.area = Some(CaptureArea::new(0, 0, 640, 480).unwrap());
assert_eq!(recording_mode(&args), RecordingMode::Screen);
assert_eq!(
selected_scale(
recording_mode(&args),
Some((640, 480)),
args.scale,
args.no_scale
)
.unwrap(),
None
);
}
#[test]
fn prompt_only_when_no_recording_choice_was_given() {
let mut args = Args::try_parse_from(["oculo"]).unwrap();
assert!(should_prompt_recording_mode(&args, true));
assert!(!should_prompt_recording_mode(&args, false));
args.mode = Some(RecordingMode::Screen);
assert!(!should_prompt_recording_mode(&args, true));
}
#[test]
fn x11_monitor_prompt_requires_matching_backend_and_display() {
assert!(should_offer_x11_monitor_prompt_for(
Backend::Auto,
true,
false
));
assert!(!should_offer_x11_monitor_prompt_for(
Backend::Auto,
true,
true
));
assert!(should_offer_x11_monitor_prompt_for(
Backend::X11,
true,
true
));
assert!(!should_offer_x11_monitor_prompt_for(
Backend::X11,
false,
false
));
assert!(!should_offer_x11_monitor_prompt_for(
Backend::Portal,
true,
false
));
}
#[test]
fn explicit_prompt_args_detect_long_and_short_options() {
let flags = explicit_prompt_args_from(["--audio=mic", "-orecording.mkv", "--hide-pointer"]);
assert!(flags.audio);
assert!(flags.output_or_dir);
assert!(flags.hide_pointer);
}
#[test]
fn explicit_prompt_args_treat_dir_as_output_choice() {
let flags = explicit_prompt_args_from(["--dir", "Videos"]);
assert!(flags.output_or_dir);
}
#[test]
fn explicit_fps_args_detect_long_options() {
assert!(explicit_fps_arg_from(["--fps", "60"]));
assert!(explicit_fps_arg_from(["--fps=60"]));
assert!(!explicit_fps_arg_from(["--format", "gif"]));
}
#[test]
fn prompted_output_path_trims_and_expands_home() {
let home = Path::new("/home/tester");
assert_eq!(
prompt_path_from_input(" ~/Videos/capture ", Some(home)).unwrap(),
PathBuf::from("/home/tester/Videos/capture")
);
assert_eq!(
prompt_path_from_input("~", Some(home)).unwrap(),
PathBuf::from("/home/tester")
);
assert_eq!(
prompt_path_from_input("~other/capture", Some(home)).unwrap(),
PathBuf::from("~other/capture")
);
assert_eq!(
prompt_path_from_input("\"~/Videos/capture with spaces\"", Some(home)).unwrap(),
PathBuf::from("/home/tester/Videos/capture with spaces")
);
assert_eq!(
prompt_path_from_input("'/tmp/capture with spaces'", Some(home)).unwrap(),
PathBuf::from("/tmp/capture with spaces")
);
assert!(prompt_path_from_input(" ", Some(home)).is_none());
}
#[test]
fn startup_wait_honors_stop_signal() {
gst::init().unwrap();
let pipeline = gst::Pipeline::builder().build();
let bus = pipeline.bus().unwrap();
let (tx, rx) = mpsc::channel();
tx.send(ControlMessage::Stop).unwrap();
let err = wait_until_playing(&pipeline, &bus, &rx, Duration::from_secs(1)).unwrap_err();
assert!(
err.to_string()
.contains("stopped before pipeline reached Playing")
);
}
#[test]
fn warning_occurrence_counts_by_source_and_message() {
let mut counts = WarningCounts::new();
assert_eq!(
warning_occurrence(&mut counts, "source", "warning"),
WarningOccurrence::First
);
assert_eq!(
warning_occurrence(&mut counts, "source", "warning"),
WarningOccurrence::Second
);
assert_eq!(
warning_occurrence(&mut counts, "source", "warning"),
WarningOccurrence::Suppressed
);
assert_eq!(
warning_occurrence(&mut counts, "other", "warning"),
WarningOccurrence::First
);
assert_eq!(
warning_occurrence(&mut counts, "source", "other"),
WarningOccurrence::First
);
}
#[test]
fn warning_occurrence_caps_distinct_messages() {
let mut counts = WarningCounts::new();
for index in 0..WARNING_COUNT_CAPACITY {
assert_eq!(
warning_occurrence(&mut counts, &format!("source-{index}"), "warning"),
WarningOccurrence::First
);
}
assert_eq!(counts.counts.len(), WARNING_COUNT_CAPACITY);
assert_eq!(
warning_occurrence(&mut counts, "extra-source", "warning"),
WarningOccurrence::CapacityReached
);
assert_eq!(
warning_occurrence(&mut counts, "another-extra-source", "warning"),
WarningOccurrence::Suppressed
);
assert_eq!(counts.counts.len(), WARNING_COUNT_CAPACITY);
}
#[test]
fn mode_area_enables_selector_when_no_area_was_given() {
let mut args =
Args::try_parse_from(["oculo", "--mode", "area", "--backend", "x11"]).unwrap();
normalize_recording_request(&mut args).unwrap();
assert!(args.select);
}
#[test]
fn wayland_selection_requires_reachable_x11_overlay() {
assert!(validate_selection_environment(true, true, false).is_err());
assert!(validate_selection_environment(true, true, true).is_ok());
assert!(validate_selection_environment(true, false, false).is_ok());
assert!(validate_selection_environment(false, true, false).is_ok());
}
#[test]
fn wayland_auto_selection_keeps_portal_recording_when_x11_overlay_is_available() {
let mut args = Args::try_parse_from(["oculo", "--select"]).unwrap();
normalize_selection_backend(&mut args, true, true).unwrap();
assert_eq!(args.backend, Backend::Auto);
}
#[test]
fn wayland_auto_selection_still_errors_without_x11() {
let mut args = Args::try_parse_from(["oculo", "--select"]).unwrap();
assert!(normalize_selection_backend(&mut args, true, false).is_err());
assert_eq!(args.backend, Backend::Auto);
}
#[test]
fn wayland_portal_selection_is_valid_with_x11_overlay() {
let mut args = Args::try_parse_from(["oculo", "--select", "--backend", "portal"]).unwrap();
assert!(normalize_selection_backend(&mut args, true, true).is_ok());
assert_eq!(args.backend, Backend::Portal);
}
#[test]
fn multiple_forces_portal_backend_when_auto() {
let mut args = Args::try_parse_from(["oculo", "--multiple"]).unwrap();
normalize_recording_request(&mut args).unwrap();
assert_eq!(args.backend, Backend::Portal);
}
#[test]
fn monitor_normalization_does_not_resolve_x11_area() {
let mut args = Args::try_parse_from(["oculo", "--monitor", "1"]).unwrap();
normalize_recording_request(&mut args).unwrap();
assert_eq!(args.backend, Backend::X11);
assert_eq!(recording_mode(&args), RecordingMode::Screen);
assert!(args.area.is_none());
}
#[test]
fn reject_contradictory_recording_modes() {
let args = Args::try_parse_from(["oculo", "--mode", "window", "--select"]).unwrap();
assert!(validate_recording_request(&args).is_err());
}
#[test]
fn reject_multiple_with_x11_backend() {
let args = Args::try_parse_from(["oculo", "--multiple", "--backend", "x11"]).unwrap();
assert!(validate_recording_request(&args).is_err());
}
#[test]
fn reject_multiple_with_monitor() {
let args = Args::try_parse_from(["oculo", "--multiple", "--monitor", "1"]).unwrap();
assert!(validate_recording_request(&args).is_err());
}
#[test]
fn reject_window_mode_with_x11_backend() {
let args = Args::try_parse_from(["oculo", "--mode", "window", "--backend", "x11"]).unwrap();
assert!(validate_recording_request(&args).is_err());
}
#[test]
fn portal_source_type_tracks_recording_mode() {
assert_eq!(
portal_source_type(RecordingMode::Screen),
portal::SourceType::MONITOR
);
assert_eq!(
portal_source_type(RecordingMode::Area),
portal::SourceType::MONITOR
);
assert_eq!(
portal_source_type(RecordingMode::Window),
portal::SourceType::WINDOW
);
}
#[test]
fn selected_area_is_trimmed_to_even_dimensions_for_subsampled_codecs() {
let area = CaptureArea::new(11, 13, 101, 99).unwrap();
let area =
normalize_selected_area_for_codec(Some(area), VideoCodec::H264(H264Encoder::OpenH264))
.unwrap()
.unwrap();
assert_eq!(area, CaptureArea::new(11, 13, 100, 98).unwrap());
let area = normalize_selected_area_for_codec(
Some(CaptureArea::new(11, 13, 101, 99).unwrap()),
VideoCodec::Mjpeg,
)
.unwrap();
assert_eq!(area, Some(CaptureArea::new(11, 13, 101, 99).unwrap()));
}
#[test]
fn selected_area_too_small_for_subsampled_codec_is_rejected() {
let err = normalize_selected_area_for_codec(
Some(CaptureArea::new(0, 0, 1, 4).unwrap()),
VideoCodec::Vp8,
)
.unwrap_err()
.to_string();
assert!(err.contains("at least 2x2"));
}
#[test]
fn default_scale_only_applies_to_full_screen_recording() {
assert_eq!(
selected_scale(RecordingMode::Screen, None, None, false).unwrap(),
Some(OutputSize {
width: 1920,
height: 1080
})
);
assert_eq!(
selected_scale(RecordingMode::Screen, Some((3840, 2160)), None, false).unwrap(),
Some(OutputSize {
width: 1920,
height: 1080
})
);
assert_eq!(
selected_scale(RecordingMode::Screen, Some((1280, 720)), None, false).unwrap(),
None
);
assert_eq!(
selected_scale(RecordingMode::Window, None, None, false).unwrap(),
None
);
assert_eq!(
selected_scale(RecordingMode::Area, None, None, false).unwrap(),
None
);
}
#[test]
fn gif_defaults_to_lower_fps_unless_explicit() {
assert_eq!(selected_fps(OutputFormat::Gif, 30, false), DEFAULT_GIF_FPS);
assert_eq!(selected_fps(OutputFormat::Gif, 30, true), 30);
assert_eq!(selected_fps(OutputFormat::Mp4, 30, false), 30);
}
#[test]
fn selected_bitrate_preserves_explicit_user_value() {
assert_eq!(
selected_bitrate(
VideoCodec::H264(H264Encoder::OpenH264),
4_200_000,
Some((1920, 1080)),
None,
30,
),
4_200_000
);
}
#[test]
fn selected_bitrate_adapts_to_effective_output_size() {
assert_eq!(
selected_bitrate(
VideoCodec::H264(H264Encoder::OpenH264),
0,
Some((160, 120)),
None,
30,
),
ADAPTIVE_H264_MIN_BITRATE
);
assert_eq!(
selected_bitrate(
VideoCodec::H264(H264Encoder::OpenH264),
0,
Some((3840, 2160)),
Some(OutputSize {
width: 1920,
height: 1080,
}),
30,
),
8_709_120
);
assert_eq!(
selected_bitrate(VideoCodec::Vp8, 0, Some((3840, 2160)), None, 60,),
ADAPTIVE_VP8_MAX_BITRATE
);
assert_eq!(
selected_bitrate(VideoCodec::Mjpeg, 0, Some((1920, 1080)), None, 30),
0
);
}
#[test]
fn portal_required_elements_include_multistream_compositor() {
assert_eq!(
portal_required_elements(false),
["pipewiresrc", "videoflip"]
);
assert_eq!(
portal_required_elements(true),
["pipewiresrc", "videoflip", "compositor"]
);
}
#[test]
fn effect_cli_choice_maps_to_pipeline_effect() {
let args = Args::try_parse_from(["oculo", "--effect", "neon"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Neon);
let args = Args::try_parse_from(["oculo", "--effect", "heatmap"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Heatmap);
let args = Args::try_parse_from(["oculo", "--effect", "glitch"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Glitch);
let args = Args::try_parse_from(["oculo", "--effect", "ripple"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Ripple);
let args = Args::try_parse_from(["oculo", "--effect", "glass"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Glass);
let args = Args::try_parse_from(["oculo", "--effect", "crt"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Crt);
let args = Args::try_parse_from(["oculo", "--effect", "holo"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Holo);
let args = Args::try_parse_from(["oculo", "--effect", "vapor"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Vapor);
let args = Args::try_parse_from(["oculo", "--effect", "fracture"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Fracture);
let args = Args::try_parse_from(["oculo", "--effect", "prism"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Prism);
let args = Args::try_parse_from(["oculo", "--effect", "aurora"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Aurora);
let args = Args::try_parse_from(["oculo", "--effect", "kaleido"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::Kaleido);
let args = Args::try_parse_from(["oculo"]).unwrap();
assert_eq!(VideoEffect::from(args.effect), VideoEffect::None);
}
#[test]
fn glsl_effects_advertise_required_plugins() {
assert!(VideoEffect::None.required_elements().is_empty());
assert_eq!(
VideoEffect::Neon.required_elements(),
[
"glupload",
"glshader",
"gldownload",
"videoconvert",
"capsfilter"
]
);
}
#[test]
fn parse_monitor_index_rejects_zero() {
assert_eq!(parse_monitor_index("1").unwrap(), 1);
assert!(parse_monitor_index("0").is_err());
}
#[test]
fn parse_selection_screen_rejects_invalid_dimensions() {
assert_eq!(
parse_selection_screen("1920x1080").unwrap(),
SelectionScreen {
width: 1920,
height: 1080,
}
);
assert!(parse_selection_screen("1920").is_err());
assert!(parse_selection_screen("0x1080").is_err());
assert!(parse_selection_screen("1920x0").is_err());
}
#[test]
fn output_format_defaults_to_mp4_and_infers_output_extension() {
let args = Args::try_parse_from(["oculo"]).unwrap();
assert_eq!(selected_output_format(&args).unwrap(), OutputFormat::Mp4);
let args = Args::try_parse_from(["oculo", "--output", "capture.mp4"]).unwrap();
assert_eq!(selected_output_format(&args).unwrap(), OutputFormat::Mp4);
let args = Args::try_parse_from(["oculo", "--output", "capture.gif"]).unwrap();
assert_eq!(selected_output_format(&args).unwrap(), OutputFormat::Gif);
let args =
Args::try_parse_from(["oculo", "--format", "mp4", "--output", "capture.mkv"]).unwrap();
assert!(selected_output_format(&args).is_err());
}
#[test]
fn mp4_rejects_non_h264_codecs() {
assert!(
choose_codec(
CliVideoCodec::Mjpeg,
CliH264Encoder::Auto,
OutputFormat::Mp4,
)
.is_err()
);
assert!(
choose_codec(CliVideoCodec::Vp8, CliH264Encoder::Auto, OutputFormat::Mp4,).is_err()
);
}
#[test]
fn gif_rejects_audio_and_explicit_video_codecs() {
let args = Args::try_parse_from(["oculo", "--format", "gif", "--audio", "mic"]).unwrap();
assert!(validate_format_options(&args, OutputFormat::Gif).is_err());
let args = Args::try_parse_from(["oculo", "--format", "gif", "--codec", "h264"]).unwrap();
assert!(validate_format_options(&args, OutputFormat::Gif).is_err());
}
#[test]
fn reject_zero_max_duration_at_parse_time() {
assert!(Args::try_parse_from(["oculo", "--max-duration", "0"]).is_err());
}
#[test]
fn reject_zero_eos_timeout_at_parse_time() {
assert!(Args::try_parse_from(["oculo", "--eos-timeout", "0"]).is_err());
}
#[test]
fn accept_zero_bitrate_as_codec_default() {
let args = Args::try_parse_from(["oculo", "--bitrate", "0"]).unwrap();
assert_eq!(args.bitrate, 0);
}
#[test]
fn reject_bitrate_that_cannot_fit_all_encoders() {
let too_large = (u64::from(MAX_VIDEO_BITRATE) + 1).to_string();
assert!(Args::try_parse_from(["oculo", "--bitrate", &too_large]).is_err());
}
}