diff --git a/Cargo.lock b/Cargo.lock index bdaf8c3..cffe32d 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1,5 +1,18 @@ # This file is automatically @generated by Cargo. # It is not intended for manual editing. +[[package]] +name = "either" +version = "1.6.1" +source = "registry+https://github.com/rust-lang/crates.io-index" + +[[package]] +name = "itertools" +version = "0.9.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +dependencies = [ + "either 1.6.1 (registry+https://github.com/rust-lang/crates.io-index)", +] + [[package]] name = "itoa" version = "0.4.6" @@ -73,11 +86,14 @@ source = "registry+https://github.com/rust-lang/crates.io-index" name = "wlstreamer" version = "0.3.0" dependencies = [ + "itertools 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)", "serde 1.0.117 (registry+https://github.com/rust-lang/crates.io-index)", "serde_json 1.0.59 (registry+https://github.com/rust-lang/crates.io-index)", ] [metadata] +"checksum either 1.6.1 (registry+https://github.com/rust-lang/crates.io-index)" = "e78d4f1cc4ae33bbfc157ed5d5a5ef3bc29227303d595861deb238fcec4e9457" +"checksum itertools 0.9.0 (registry+https://github.com/rust-lang/crates.io-index)" = "284f18f85651fe11e8a991b2adb42cb078325c996ed026d994719efcfca1d54b" "checksum itoa 0.4.6 (registry+https://github.com/rust-lang/crates.io-index)" = "dc6f3ad7b9d11a0c00842ff8de1b60ee58661048eb8049ed33c73594f359d7e6" "checksum proc-macro2 1.0.24 (registry+https://github.com/rust-lang/crates.io-index)" = "1e0704ee1a7e00d7bb417d0770ea303c1bccbabf0ef1667dae92b5967f5f8a71" "checksum quote 1.0.7 (registry+https://github.com/rust-lang/crates.io-index)" = "aa563d17ecb180e500da1cfd2b028310ac758de548efdd203e18f283af693f37" diff --git a/Cargo.toml b/Cargo.toml index 5ab7e07..652fb25 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -7,3 +7,4 @@ edition = "2018" [dependencies] serde = { version = "1.0", features = ["derive"]} serde_json = "1.0" +itertools = "0.9" diff --git a/src/main.rs b/src/main.rs index f519f28..307d42b 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,66 +1,218 @@ +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}; -#[derive(Serialize, Deserialize, Debug)] +#[derive(Serialize, Deserialize, Clone, Debug)] +struct SwayScreenRect { + x: usize, + y: usize, + width: usize, + height: usize, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] struct SwayWorkspace { - id: u32, + id: usize, name: String, - focus: Vec, + focus: Vec, output: String, focused: bool, + rect: SwayScreenRect, visible: bool, - num: u32, + num: usize, #[serde(rename = "type")] type_name: String, representation: String, } +#[derive(Serialize, Deserialize, Clone, Debug)] +struct SwayOutputMode { + width: usize, + height: usize, + refresh: usize, +} + +#[derive(Serialize, Deserialize, Clone, Debug)] +struct SwayOutput { + id: usize, + name: String, + rect: SwayScreenRect, + layout: String, + orientation: String, + #[serde(rename = "type")] + type_name: String, + make: String, + model: String, + serial: String, + current_mode: SwayOutputMode, + focused: bool, +} + +#[derive(Copy, Clone, Hash, Eq, Debug)] +struct Resolution { + height: usize, + width: usize, +} + +#[derive(Debug)] struct Config { - current_screen: String, - output: String, + current_output: String, + devices_from: usize, + current_device_index: usize, screen_blacklist: Vec, - workspace_blacklist: Vec, + workspace_blacklist: Vec, verbose: bool, + resolutions: Vec, + outputs: HashMap, +} + +impl PartialEq 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!("Wrapper around wf-recorder and ffmpeg that automatically switches the screen being recorded based on current window focus"); - println!(""); 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 Do not show this workspace. Can be used multiple times. Example: 3"); println!(" --not-screen Do not show this screen. Can be used multiple times. Example: HDMI-A-1"); - println!(" -o|--output Output to this device. Defaults to /dev/video0"); + println!(" -d|--devices-from 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."); + println!("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!("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 record_screen(config: &mut Config, valid_screens: &Vec) -> Result { - if valid_screens.len() == 0 { - let cmd = Command::new("ffmpeg") +fn stream_black(config: &mut Config) -> Result, Error> { + let mut 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![cmd]); +} + +fn record_screen(config: &mut Config, output: SwayOutput) -> Result, 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 + .outputs + .insert(resolution, config.current_device_index); + config.current_device_index += 1; + config.current_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![recorder]; + + if device_number != config.devices_from { + if config.verbose { + println!("Does not have the maximum combined resolution, filtering through ffmpeg"); + } + + let upscaler = Command::new("ffmpeg") .args(&[ - "-f", - "lavfi", "-i", - "color=c=black:s=1920x1080:r=25/1", + format!("/dev/video{}", device_number).as_str(), "-vcodec", "rawvideo", "-pix_fmt", "yuyv422", "-f", "v4l2", - config.output.as_str(), + "-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 { @@ -75,46 +227,94 @@ fn record_screen(config: &mut Config, valid_screens: &Vec) -> Result Vec { - let mut command = "swaymsg -t get_workspaces"; +fn get_outputs(config: &mut Config) -> Vec { + let command = "swaymsg -t get_outputs"; let output = Command::new("sh") .args(&["-c", command]) .output() - .expect("Couldn't get current focus"); + .expect("Error running swaymsg"); + + let stdout_string = String::from_utf8(output.stdout).expect("Invalid UTF-8 from get_outputs"); + let outputs: Vec = + 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 { + let outputs = get_outputs(config); + let mut resolutions: Vec = outputs + .iter() + .map(|o| Resolution { + height: o.current_mode.height, + width: o.current_mode.width, + }) + .unique() + .collect_vec(); + + if config.verbose { + 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!("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 { + 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"); @@ -156,16 +356,20 @@ fn get_valid_screens_for_recording(config: &Config) -> Vec { Ordering::Greater } }); - return workspaces.into_iter().map(|w| w.output).collect(); + + return workspaces; } fn main() -> Result<(), Box> { let mut config = Config { - current_screen: "".to_string(), - output: "/dev/video0".to_string(), + current_output: "".to_string(), + devices_from: 0, + current_device_index: 0, screen_blacklist: Vec::new(), workspace_blacklist: Vec::new(), verbose: false, + resolutions: Vec::new(), + outputs: HashMap::new(), }; let args: Vec = env::args().collect(); @@ -180,13 +384,14 @@ fn main() -> Result<(), Box> { i += 1; config .workspace_blacklist - .push(args[i].clone().parse::().unwrap()); + .push(args[i].clone().parse::().unwrap()); } else if arg == "--not-screen" { i += 1; config.screen_blacklist.push(args[i].clone()); - } else if arg == "-o" || arg == "--output" { + } else if arg == "-d" || arg == "--devices-from" { i += 1; - config.output = args[i].clone(); + config.devices_from = args[i].clone().parse::().unwrap(); + config.current_device_index = config.devices_from; } else if arg == "--verbose" { config.verbose = true; } else if arg == "-v" || arg == "--version" { @@ -201,8 +406,11 @@ fn main() -> Result<(), Box> { i += 1; } - let valid_screens = get_valid_screens_for_recording(&config); - let mut recorder = record_screen(&mut config, &valid_screens)?; + config.resolutions = get_resolutions(&mut config); + config + .outputs + .insert(config.resolutions[0], config.devices_from); + config.current_device_index += 1; let stdout = match Command::new("sh") .args(&["-c", "swaymsg -t subscribe -m \"['window']\""]) @@ -213,28 +421,37 @@ fn main() -> Result<(), Box> { .stdout { Some(stdout) => stdout, - None => panic!("Could not open stdout"), + None => panic!("Could not open swaymsg stdout"), }; let reader = BufReader::new(stdout); - reader.lines().filter_map(|line| line.ok()).for_each(|_| { - println!("Switched focus"); - let valid_screens = get_valid_screens_for_recording(&config); - if valid_screens.len() > 0 && valid_screens[0] == config.current_screen { - return; - } + reader + .lines() + .filter_map(|line| line.ok()) + .for_each(|_| -> Result<(), Error> { + println!("Switched focus"); + let valid_screens = get_valid_screens_for_recording(&config); + if valid_screens.len() > 0 && valid_screens[0].output == config.current_output { + return Ok(()); + } + let output = get_output(&mut config, valid_screens[0].output.as_str()); + for recorder in recorders.iter() { + match recorder.kill() { + Ok(_) => {} + Err(err) => panic!("{:?}", err), + }; + } - match recorder.kill() { - Ok(_) => {} - Err(err) => panic!("{:?}", err), - }; + let mut recorders: Vec = 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)? + }; + println!("Recording {}", config.current_output); - recorder = match record_screen(&mut config, &valid_screens) { - Ok(recorder) => recorder, - Err(err) => panic!("{:?}", err), - }; - println!("Recording {}", config.current_screen); - }); + Ok(()) + }); Ok(()) }