458 lines
14 KiB
Rust
458 lines
14 KiB
Rust
use itertools::Itertools;
|
|
use serde::{Deserialize, Serialize};
|
|
use std::cmp::Ordering;
|
|
use std::collections::HashMap;
|
|
use std::env;
|
|
use std::io::{BufRead, BufReader, Error};
|
|
use std::process::{Child, Command, Stdio};
|
|
use std::{thread, time};
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
struct SwayScreenRect {
|
|
x: usize,
|
|
y: usize,
|
|
width: usize,
|
|
height: usize,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
struct SwayWorkspace {
|
|
name: String,
|
|
focus: Vec<usize>,
|
|
output: String,
|
|
focused: bool,
|
|
rect: SwayScreenRect,
|
|
visible: bool,
|
|
num: usize,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
struct SwayOutputMode {
|
|
width: usize,
|
|
height: usize,
|
|
refresh: usize,
|
|
}
|
|
|
|
#[derive(Serialize, Deserialize, Clone, Debug)]
|
|
struct SwayOutput {
|
|
name: String,
|
|
rect: SwayScreenRect,
|
|
current_mode: SwayOutputMode,
|
|
}
|
|
|
|
#[derive(Copy, Clone, Hash, Eq, Debug)]
|
|
struct Resolution {
|
|
height: usize,
|
|
width: usize,
|
|
}
|
|
|
|
#[derive(Debug)]
|
|
struct Config {
|
|
current_output: String,
|
|
devices_from: usize,
|
|
last_device_index: usize,
|
|
screen_blacklist: Vec<String>,
|
|
workspace_blacklist: Vec<usize>,
|
|
verbose: bool,
|
|
resolutions: Vec<Resolution>,
|
|
outputs: HashMap<Resolution, usize>,
|
|
}
|
|
|
|
impl PartialEq<Resolution> for Resolution {
|
|
fn eq(&self, other: &Resolution) -> bool {
|
|
self.width == other.width && self.height == other.height
|
|
}
|
|
}
|
|
|
|
const VERSION: &'static str = env!("CARGO_PKG_VERSION");
|
|
|
|
fn help() {
|
|
println!("Usage: wlstreamer [options]");
|
|
println!("Wrapper around wf-recorder and ffmpeg that automatically switches the screen being recorded based on current window focus");
|
|
println!("");
|
|
println!("Options:");
|
|
println!(" --not-ws <ws-num> Do not show this workspace. Can be used multiple times. Example: 3");
|
|
println!(" --not-screen <screen> Do not show this screen. Can be used multiple times. Example: HDMI-A-1");
|
|
println!(" -d|--devices-from <id> Use video devices starting at $id. Defaults to 0. /dev/video$id will be used as output. See DIFFERENT RESOLUTIONS below.");
|
|
println!(" -v|--version Display version and exit");
|
|
println!(" --verbose Verbose logging");
|
|
println!("");
|
|
println!(
|
|
"If there are no screens available for streaming, a black screen will be shown instead."
|
|
);
|
|
println!("");
|
|
println!("DIFFERENT RESOLUTIONS");
|
|
println!("");
|
|
println!("When running outputs with different resolutions, the resulting stream will be the smallest possible resolution that can fit all output resolutions.");
|
|
println!("For example, two outputs, one 1600x1200, another 1920x1080, will result in an output stream of 1920x1200. Any remaining space will be padded black.");
|
|
println!("Another example, two outputs, one 640x480, another 1920x1080, will result in an output stream of 1920x1080. Space will only be padded black on the smaller screen.");
|
|
println!("");
|
|
println!("To support this behaviour, wlstreamer needs access to a v4l2loopback device for each resolution, included the combined upscaled one if applicable. For the first example above, this would mean you would need 3 devices. For the second, you'd need two. If all your outputs have the same resolution, you only need an output device.");
|
|
println!("");
|
|
println!("The --devices-from or -d option specifies at which device index it is okay to start using loopback devices. For example, if you specify -d 3, and you need 2 capture devices, /dev/video3 and /dev/video4 will be used by wlstreamer, with /dev/video3 being the output you want to use in other applications.");
|
|
println!("");
|
|
println!("DYNAMICALLY CHANGING RESOLUTIONS");
|
|
println!("");
|
|
println!("As long as you have enough v4l2loopback devices available for new resolutions, it should be fine to change resolutions on an output.");
|
|
println!("However, if your resolution is either wider or taller than the output resolution, this will result in failures, since dynamically changing the v4l2loopback device resolution is not possible.");
|
|
|
|
std::process::exit(0);
|
|
}
|
|
|
|
fn stream_black(config: &mut Config) -> Result<Vec<Box<Child>>, Error> {
|
|
let cmd = Command::new("ffmpeg")
|
|
.args(&[
|
|
"-i",
|
|
format!(
|
|
"color=c=black:s={}x{}:r=25/1",
|
|
config.resolutions[0].width, config.resolutions[0].height
|
|
)
|
|
.as_str(),
|
|
"-vcodec",
|
|
"rawvideo",
|
|
"-pix_fmt",
|
|
"yuyv422",
|
|
"-f",
|
|
"v4l2",
|
|
format!("/dev/video{}", config.devices_from).as_str(),
|
|
])
|
|
.stdin(Stdio::piped())
|
|
.stdout(if config.verbose {
|
|
Stdio::piped()
|
|
} else {
|
|
Stdio::inherit()
|
|
})
|
|
.stderr(if config.verbose {
|
|
Stdio::piped()
|
|
} else {
|
|
Stdio::inherit()
|
|
})
|
|
.spawn()?;
|
|
|
|
config.current_output = "".to_string();
|
|
|
|
return Ok(vec![Box::new(cmd)]);
|
|
}
|
|
|
|
fn record_screen(config: &mut Config, output: SwayOutput) -> Result<Vec<Box<Child>>, Error> {
|
|
let resolution = Resolution {
|
|
height: output.current_mode.height,
|
|
width: output.current_mode.width,
|
|
};
|
|
|
|
let device_number = match config.outputs.get(&resolution) {
|
|
Some(device_number) => *device_number,
|
|
None => {
|
|
config.last_device_index += 1;
|
|
config.outputs.insert(resolution, config.last_device_index);
|
|
config.last_device_index
|
|
}
|
|
};
|
|
|
|
if config.verbose {
|
|
println!("Using device number {}", device_number);
|
|
}
|
|
|
|
let output_str = format!("--file=/dev/video{}", device_number);
|
|
let screen_str = format!("-o{}", output.name.as_str());
|
|
let recorder = Command::new("wf-recorder")
|
|
.args(&[
|
|
"--muxer=v4l2",
|
|
"--codec=rawvideo",
|
|
"--pixel-format=yuyv422",
|
|
screen_str.as_str(),
|
|
output_str.as_str(),
|
|
])
|
|
.stdin(Stdio::piped())
|
|
.stdout(if config.verbose {
|
|
Stdio::inherit()
|
|
} else {
|
|
Stdio::piped()
|
|
})
|
|
.stderr(if config.verbose {
|
|
Stdio::inherit()
|
|
} else {
|
|
Stdio::piped()
|
|
})
|
|
.spawn()?;
|
|
|
|
config.current_output = output.name.as_str().to_string();
|
|
|
|
let mut processes = vec![Box::new(recorder)];
|
|
|
|
if device_number != config.devices_from {
|
|
if config.verbose {
|
|
println!("Does not have the maximum combined resolution, filtering through ffmpeg");
|
|
}
|
|
|
|
// TODO: This is slow, ugly, and prone to failure. ffmpeg will fail if wf-recorder isn't
|
|
// writing yet, however I'm not sure how to get an exact timing of when it's okay to start
|
|
// reading from the device.
|
|
thread::sleep(time::Duration::from_millis(100));
|
|
|
|
let upscaler = Command::new("ffmpeg")
|
|
.args(&[
|
|
"-i",
|
|
format!("/dev/video{}", device_number).as_str(),
|
|
"-vcodec",
|
|
"rawvideo",
|
|
"-pix_fmt",
|
|
"yuyv422",
|
|
"-f",
|
|
"v4l2",
|
|
"-vf",
|
|
format!("scale={}:{}:force_original_aspect_ratio=decrease,pad={}:{}:(ow-iw)/2:(oh-ih)/2,setsar=1",
|
|
config.resolutions[0].width, config.resolutions[0].height,
|
|
config.resolutions[0].width, config.resolutions[0].height).as_str(),
|
|
format!("/dev/video{}", config.devices_from).as_str(),
|
|
])
|
|
.stdin(Stdio::piped())
|
|
.stdout(if config.verbose {
|
|
Stdio::inherit()
|
|
} else {
|
|
Stdio::piped()
|
|
})
|
|
.stderr(if config.verbose {
|
|
Stdio::inherit()
|
|
} else {
|
|
Stdio::piped()
|
|
})
|
|
.spawn()?;
|
|
|
|
processes.push(Box::new(upscaler));
|
|
}
|
|
|
|
return Ok(processes);
|
|
}
|
|
|
|
fn get_outputs(config: &mut Config) -> Vec<SwayOutput> {
|
|
let command = "swaymsg -t get_outputs";
|
|
let output = Command::new("sh")
|
|
.args(&["-c", command])
|
|
.output()
|
|
.expect("Error running swaymsg");
|
|
|
|
let stdout_string = String::from_utf8(output.stdout).expect("Invalid UTF-8 from get_outputs");
|
|
let outputs: Vec<SwayOutput> =
|
|
serde_json::from_str(stdout_string.as_str()).expect("Invalid json from get_outputs");
|
|
|
|
if config.verbose {
|
|
println!("Found outputs");
|
|
for elem in outputs.iter() {
|
|
println!("{:?}", elem);
|
|
}
|
|
}
|
|
|
|
return outputs;
|
|
}
|
|
|
|
fn get_output(config: &mut Config, screen: &str) -> SwayOutput {
|
|
let outputs = get_outputs(config);
|
|
let output = match outputs.iter().find(|o| o.name == screen) {
|
|
Some(o) => o.to_owned(),
|
|
None => panic!("Could not find output"),
|
|
};
|
|
|
|
return output.clone();
|
|
}
|
|
|
|
fn get_resolutions(config: &mut Config) -> Vec<Resolution> {
|
|
let outputs = get_outputs(config);
|
|
let mut resolutions: Vec<Resolution> = outputs
|
|
.iter()
|
|
.map(|o| Resolution {
|
|
height: o.current_mode.height,
|
|
width: o.current_mode.width,
|
|
})
|
|
.unique()
|
|
.collect_vec();
|
|
|
|
if config.verbose {
|
|
println!("Found resolutions:");
|
|
println!("{:?}", resolutions);
|
|
}
|
|
|
|
let combined_resolution: Resolution = resolutions.iter().fold(
|
|
Resolution {
|
|
width: 0,
|
|
height: 0,
|
|
},
|
|
|acc, r| Resolution {
|
|
height: if acc.height > r.height {
|
|
acc.height
|
|
} else {
|
|
r.height
|
|
},
|
|
width: if acc.width > r.width {
|
|
acc.width
|
|
} else {
|
|
r.width
|
|
},
|
|
},
|
|
);
|
|
|
|
if config.verbose {
|
|
println!(
|
|
"Found combined maximum resolution {:?}",
|
|
combined_resolution
|
|
);
|
|
}
|
|
|
|
resolutions.insert(0, combined_resolution);
|
|
resolutions = resolutions.into_iter().unique().collect_vec();
|
|
|
|
return resolutions;
|
|
}
|
|
|
|
fn get_valid_screens_for_recording(config: &Config) -> Vec<SwayWorkspace> {
|
|
let command = "swaymsg -t get_workspaces";
|
|
let output = Command::new("sh")
|
|
.args(&["-c", command])
|
|
.output()
|
|
.expect("Error running swaymsg");
|
|
|
|
let stdout_string =
|
|
String::from_utf8(output.stdout).expect("Invalid UTF-8 from get_workspaces");
|
|
let mut workspaces: Vec<SwayWorkspace> =
|
|
serde_json::from_str(stdout_string.as_str()).expect("Invalid json from get_workspaces");
|
|
|
|
if config.verbose {
|
|
println!("Found workspaces:");
|
|
for elem in workspaces.iter() {
|
|
println!("{:?}", elem);
|
|
}
|
|
}
|
|
|
|
workspaces = workspaces
|
|
.into_iter()
|
|
.filter(|w| {
|
|
w.visible
|
|
&& !config
|
|
.screen_blacklist
|
|
.iter()
|
|
.any(|screen| screen.eq(&w.output))
|
|
&& !config.workspace_blacklist.iter().any(|&num| num == w.num)
|
|
})
|
|
.collect();
|
|
|
|
if config.verbose {
|
|
println!("Blacklisted workspaces filtered out:");
|
|
for elem in workspaces.iter() {
|
|
println!("{:?}", elem);
|
|
}
|
|
}
|
|
|
|
workspaces.sort_by(|a, b| {
|
|
if a.focused && !b.focused {
|
|
Ordering::Less
|
|
} else if a.focused == b.focused {
|
|
Ordering::Equal
|
|
} else {
|
|
Ordering::Greater
|
|
}
|
|
});
|
|
|
|
return workspaces;
|
|
}
|
|
|
|
fn main() -> Result<(), Box<dyn std::error::Error>> {
|
|
let mut config = Config {
|
|
current_output: "".to_string(),
|
|
devices_from: 0,
|
|
last_device_index: 0,
|
|
screen_blacklist: Vec::new(),
|
|
workspace_blacklist: Vec::new(),
|
|
verbose: false,
|
|
resolutions: Vec::new(),
|
|
outputs: HashMap::new(),
|
|
};
|
|
let args: Vec<String> = env::args().collect();
|
|
|
|
let mut i = 1;
|
|
loop {
|
|
if i >= args.len() {
|
|
break;
|
|
}
|
|
|
|
let arg = &args[i];
|
|
if arg == "--not-ws" {
|
|
i += 1;
|
|
config
|
|
.workspace_blacklist
|
|
.push(args[i].clone().parse::<usize>().unwrap());
|
|
} else if arg == "--not-screen" {
|
|
i += 1;
|
|
config.screen_blacklist.push(args[i].clone());
|
|
} else if arg == "-d" || arg == "--devices-from" {
|
|
i += 1;
|
|
config.devices_from = args[i].clone().parse::<usize>().unwrap();
|
|
} else if arg == "--verbose" {
|
|
config.verbose = true;
|
|
} else if arg == "-v" || arg == "--version" {
|
|
println!("v{}", VERSION);
|
|
std::process::exit(0);
|
|
} else if arg == "-h" || arg == "--help" {
|
|
help();
|
|
} else {
|
|
println!("Unknown option: {}", arg);
|
|
help();
|
|
}
|
|
i += 1;
|
|
}
|
|
|
|
config.resolutions = get_resolutions(&mut config);
|
|
config
|
|
.outputs
|
|
.insert(config.resolutions[0], config.devices_from);
|
|
config.last_device_index = config.devices_from;
|
|
let valid_screens = get_valid_screens_for_recording(&config);
|
|
let mut recorders: Vec<Box<Child>> = if valid_screens.len() == 0 {
|
|
stream_black(&mut config)?
|
|
} else {
|
|
let output = get_output(&mut config, valid_screens[0].output.as_str());
|
|
record_screen(&mut config, output)?
|
|
};
|
|
|
|
let stdout = match Command::new("sh")
|
|
.args(&["-c", "swaymsg -t subscribe -m \"['window']\""])
|
|
.stdout(Stdio::piped())
|
|
.stderr(Stdio::piped())
|
|
.stdin(Stdio::piped())
|
|
.spawn()?
|
|
.stdout
|
|
{
|
|
Some(stdout) => stdout,
|
|
None => panic!("Could not open swaymsg stdout"),
|
|
};
|
|
|
|
let reader = BufReader::new(stdout);
|
|
reader.lines().filter_map(|line| line.ok()).for_each(|_| {
|
|
println!("Focus switched event");
|
|
let valid_screens = get_valid_screens_for_recording(&config);
|
|
if valid_screens.len() > 0 && valid_screens[0].output == config.current_output {
|
|
println!("Screen is the same, no need to switch");
|
|
return;
|
|
}
|
|
for recorder in recorders.iter_mut() {
|
|
if config.verbose {
|
|
println!("Killing child");
|
|
}
|
|
match recorder.kill() {
|
|
Ok(_) => {}
|
|
Err(err) => panic!("{:?}", err),
|
|
};
|
|
}
|
|
|
|
recorders = if valid_screens.len() == 0 {
|
|
stream_black(&mut config).unwrap()
|
|
} else {
|
|
let output = get_output(&mut config, valid_screens[0].output.as_str());
|
|
record_screen(&mut config, output).unwrap()
|
|
};
|
|
|
|
println!("Recording {}", config.current_output);
|
|
});
|
|
|
|
Ok(())
|
|
}
|