diff --git a/Cargo.lock b/Cargo.lock index fe1f117..cd81b25 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -809,6 +809,7 @@ dependencies = [ "core-foundation", "core-foundation-sys", "core-graphics", + "crossbeam-channel", "env_logger", "humantime", "image", diff --git a/Cargo.toml b/Cargo.toml index 8919767..2f90f55 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -26,6 +26,7 @@ log = "0.4.14" env_logger = "0.9.0" simplerand = "1.3.0" humantime = "2.1.0" +crossbeam-channel = "0.5" [dependencies.clap] version = "3.1.6" @@ -52,7 +53,7 @@ e2e_tests = [] section = "x11" depends = "imagemagick" extended-description = """## Features -- Screenshotting your terminal with 4 frames per second (every 250ms) +- Screenshotting your terminal with 4/8 frames per second - Generates high quality small sized animated gif images - **Build-In idle frames detection and optimization** (for super fluid presentations) diff --git a/README.md b/README.md index be0f8d7..4a824d3 100644 --- a/README.md +++ b/README.md @@ -18,11 +18,11 @@ Blazingly fast terminal recorder that generates animated gif images for the web ![demo](./docs/demo.gif) ## Features -- Screenshotting your terminal with 4 frames per second (every 250ms) -- Generates high quality small sized animated gif images or mp4 videos +- Screenshotting your terminal with 4/8 frames per second +- Generates high-quality small-sized animated gif images or mp4 videos - **Build-In idle frames detection and optimization** (for super fluid presentations) - Applies (can be disabled) border decor effects like drop shadow -- Runs on MacOS and Linux +- Runs on macOS and Linux - Uses native efficient APIs - Runs without any cloud service and entirely offline - No issues with terminal sizes larger than 80x24 @@ -123,7 +123,7 @@ t-rec /bin/sh ### Full Options ```sh -t-rec 0.7.0 +t-rec 0.7.1 Sven Assmann Blazingly fast terminal recorder that generates animated gif images for the web written in rust. @@ -135,28 +135,56 @@ ARGS: pass it here. For example '/bin/sh' OPTIONS: - -b, --bg Background color when decors are used [default: transparent] - [possible values: white, black, transparent] - -d, --decor Decorates the animation with certain, mostly border effects - [default: none] [possible values: shadow, none] - -e, --end-pause to specify the pause time at the end of the animation, that - time the gif will show the last frame - -h, --help Print help information - -l, --ls-win If you want to see a list of windows available for recording - by their id, you can set env var 'WINDOWID' or `--win-id` to - record this specific window only - -m, --video Generates additionally to the gif a mp4 video of the recording - -M, --video-only Generates only a mp4 video and not gif - -n, --natural If you want a very natural typing experience and disable the - idle detection and sampling optimization - -q, --quiet Quiet mode, suppresses the banner: 'Press Ctrl+D to end - recording' - -s, --start-pause to specify the pause time at the start of the animation, that - time the gif will show the first frame - -v, --verbose Enable verbose insights for the curious - -V, --version Print version information - -w, --win-id Window Id (see --ls-win) that should be captured, instead of - the current terminal + -b, --bg + Background color when decors are used [default: transparent] [possible values: white, + black, transparent] + + -d, --decor + Decorates the animation with certain, mostly border effects [default: none] [possible + values: shadow, none] + + -e, --end-pause + Specify the pause time at the end of the animation, that time the gif will show the last + frame + + -f, --framerate + Increase the screen capturing rate (framerate) [default: 4] [possible values: 4, 8] + + -h, --help + Print help information + + -l, --ls-win + If you want to see a list of windows available for recording by their id, you can set + env var 'WINDOWID' or `--win-id` to record this specific window only + + -m, --video + Generates additionally to the gif a mp4 video of the recording + + -M, --video-only + Generates only a mp4 video and not gif + + -n, --natural + If you want a very natural typing experience and disable the idle detection and sampling + optimization + + -o, --output + Specify the output file (without extension) [default: t-rec] + + -q, --quiet + Quiet mode, suppresses the banner: 'Press Ctrl+D to end recording' + + -s, --start-pause + Specify the pause time at the start of the animation, that time the gif will show the + first frame + + -v, --verbose + Enable verbose insights for the curious + + -V, --version + Print version information + + -w, --win-id + Window Id (see --ls-win) that should be captured, instead of the current terminal ``` ### Disable idle detection & optimization diff --git a/src/capture.rs b/src/capture.rs deleted file mode 100644 index 01fcf3c..0000000 --- a/src/capture.rs +++ /dev/null @@ -1,87 +0,0 @@ -use anyhow::{Context, Result}; -use image::save_buffer; -use image::ColorType::Rgba8; -use std::borrow::Borrow; -use std::ops::{Add, Sub}; -use std::sync::mpsc::Receiver; -use std::sync::{Arc, Mutex}; -use std::time::{Duration, Instant}; -use tempfile::TempDir; - -use crate::utils::{file_name_for, IMG_EXT}; -use crate::{ImageOnHeap, PlatformApi, WindowId}; - -/// captures screenshots as file on disk -/// collects also the timecodes when they have been captured -/// stops once receiving something in rx -pub fn capture_thread( - rx: &Receiver<()>, - api: impl PlatformApi, - win_id: WindowId, - time_codes: Arc>>, - tempdir: Arc>, - force_natural: bool, -) -> Result<()> { - let duration = Duration::from_millis(250); - let start = Instant::now(); - let mut idle_duration = Duration::from_millis(0); - let mut last_frame: Option = None; - let mut identical_frames = 0; - let mut last_now = Instant::now(); - loop { - // blocks for a timeout - if rx.recv_timeout(duration).is_ok() { - break; - } - let now = Instant::now(); - let effective_now = now.sub(idle_duration); - let tc = effective_now.saturating_duration_since(start).as_millis(); - let image = api.capture_window_screenshot(win_id)?; - if !force_natural { - if last_frame.is_some() - && image - .samples - .as_slice() - .eq(last_frame.as_ref().unwrap().samples.as_slice()) - { - identical_frames += 1; - } else { - identical_frames = 0; - } - } - - if identical_frames > 0 { - // let's track now the duration as idle - idle_duration = idle_duration.add(now.duration_since(last_now)); - } else { - if let Err(e) = save_frame(&image, tc, tempdir.lock().unwrap().borrow(), file_name_for) - { - eprintln!("{}", &e); - return Err(e); - } - time_codes.lock().unwrap().push(tc); - last_frame = Some(image); - identical_frames = 0; - } - last_now = now; - } - - Ok(()) -} - -/// saves a frame as a tga file -pub fn save_frame( - image: &ImageOnHeap, - time_code: u128, - tempdir: &TempDir, - file_name_for: fn(&u128, &str) -> String, -) -> Result<()> { - save_buffer( - tempdir.path().join(file_name_for(&time_code, IMG_EXT)), - &image.samples, - image.layout.width, - image.layout.height, - image.color_hint.unwrap_or(Rgba8), - ) - .context("Cannot save frame") -} diff --git a/src/capture/framerate.rs b/src/capture/framerate.rs new file mode 100644 index 0000000..cface3c --- /dev/null +++ b/src/capture/framerate.rs @@ -0,0 +1,29 @@ +use std::fmt::{Display, Formatter}; + +#[derive(Clone)] +pub struct Framerate(u32); + +impl Framerate { + pub fn new(f: u32) -> Self { + Self(f) + } +} + +impl Display for Framerate { + fn fmt(&self, f: &mut Formatter<'_>) -> std::fmt::Result { + let framerate = self.0; + write!(f, "framerate {framerate} [fps]") + } +} + +impl From for Framerate { + fn from(fr: u32) -> Self { + Self(fr) + } +} + +impl AsRef for Framerate { + fn as_ref(&self) -> &u32 { + &self.0 + } +} diff --git a/src/capture/mod.rs b/src/capture/mod.rs new file mode 100644 index 0000000..8c3042a --- /dev/null +++ b/src/capture/mod.rs @@ -0,0 +1,5 @@ +mod framerate; +mod processor; + +pub use framerate::*; +pub use processor::*; diff --git a/src/capture/processor.rs b/src/capture/processor.rs new file mode 100644 index 0000000..699ea13 --- /dev/null +++ b/src/capture/processor.rs @@ -0,0 +1,161 @@ +use anyhow::Context; +use crossbeam_channel::Receiver; +use image::save_buffer; +use image::ColorType::Rgba8; +use rayon::ThreadPoolBuilder; +use std::borrow::Borrow; +use std::ops::Sub; +use std::sync::{Arc, Mutex}; +use std::time::{Duration, Instant}; +use tempfile::TempDir; + +use crate::capture::Framerate; +use crate::utils::{file_name_for, IMG_EXT}; +use crate::{ImageOnHeap, PlatformApi, Result, WindowId}; + +#[derive(Eq, PartialEq)] +pub enum FrameDropStrategy { + DoNotDropAny, + DropIdenticalFrames, +} + +#[derive(Clone)] +struct FrameComparator<'a> { + last_frame: Option, + strategy: &'a FrameDropStrategy, +} + +impl<'a> FrameComparator<'a> { + pub fn drop_frame(&mut self, frame: ImageOnHeap) -> bool { + if self.last_frame.is_none() { + self.last_frame = Some(frame); + false + } else { + false + } + } +} + +/// captures screenshots as file on disk +/// collects also the timecodes when they have been captured +/// stops once receiving something in rx +pub fn capture_thread( + rx: &Receiver<()>, + api: impl PlatformApi + Sync, + win_id: WindowId, + time_codes: Arc>>, + tempdir: Arc, + frame_drop_strategy: &FrameDropStrategy, + framerate: &Framerate, +) -> Result<()> { + let pool = ThreadPoolBuilder::default().build()?; + let duration = Duration::from_secs(1) / *framerate.as_ref(); + let start = Instant::now(); + let mut idle_duration = Duration::from_millis(0); + let mut last_frame: Option = None; + let mut identical_frames = 0; + let mut last_time = Instant::now(); + let api = Arc::new(api); + let comp = Arc::new(FrameComparator { + last_frame: None, + strategy: frame_drop_strategy, + }); + // let rx = Arc::new(rx); + // let mut results: Arc>>> = Arc::new(Mutex::new(Vec::new())); + + pool.scope(|s| { + loop { + let delta = Instant::now().saturating_duration_since(last_time); + let sleep_time = duration.sub(delta); + // thread::sleep(sleep_time); + // blocks for a timeout + if rx.recv_timeout(sleep_time).is_ok() { + if pool.current_thread_has_pending_tasks().unwrap_or(false) { + println!( + "there is a backlog of frames that needs to be persisted, this may take a bit ...", + ); + } + return; + } + let now = Instant::now(); + let timecode = now.saturating_duration_since(start).as_millis(); + // let effective_now = now.sub(idle_duration); + let api = api.clone(); + let tempdir = tempdir.clone(); + time_codes.lock().unwrap().push(timecode); + + s.spawn(move |_| { + let frame = api.capture_window_screenshot(win_id); + + if let Ok(frame) = frame { + save_frame(&frame, timecode, tempdir.borrow(), file_name_for).unwrap(); + // results.borrow_mut().lock().unwrap().push(result); + } + }); + + /* + let image = api.capture_window_screenshot(win_id)?; + if frame_drop_strategy == &FrameDropStrategy::DropIdenticalFrames { + if last_frame.is_some() + && image + .samples + .as_slice() + .eq(last_frame.as_ref().unwrap().samples.as_slice()) + { + identical_frames += 1; + } else { + identical_frames = 0; + } + } + + if identical_frames > 0 { + // let's track now the duration as idle + idle_duration = idle_duration.add(now.duration_since(last_now)); + } else { + if let Err(e) = save_frame(&image, tc, tempdir.lock().unwrap().borrow(), file_name_for) + { + eprintln!("{}", &e); + return Err(e); + } + time_codes.lock().unwrap().push(tc); + last_frame = Some(image); + identical_frames = 0; + } + */ + + last_time = now; + } + }); + + Ok(()) +} + +fn capture_and_save_frame( + api: Arc, + win_id: WindowId, + timecode: u128, + tempdir: Arc>, + file_name_fn: fn(&u128, &str) -> String, +) -> Result<()> { + let mut result: Result<()> = Ok(()); + rayon::scope(|s| s.spawn(|_| {})); + + Ok(()) +} + +/// saves a frame as a tga file +pub fn save_frame( + image: &ImageOnHeap, + time_code: u128, + tempdir: &TempDir, + file_name_for: fn(&u128, &str) -> String, +) -> Result<()> { + save_buffer( + tempdir.path().join(file_name_for(&time_code, IMG_EXT)), + &image.samples, + image.layout.width, + image.layout.height, + image.color_hint.unwrap_or(Rgba8), + ) + .context("Cannot save frame") +} diff --git a/src/cli.rs b/src/cli.rs index 5376fa6..c6d3ece 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -91,7 +91,7 @@ pub fn launch() -> ArgMatches { .required(false) .short('e') .long("end-pause") - .help("to specify the pause time at the end of the animation, that time the gif will show the last frame"), + .help("Specify the pause time at the end of the animation, that time the gif will show the last frame"), ) .arg( Arg::new("start-pause") @@ -100,7 +100,7 @@ pub fn launch() -> ArgMatches { .required(false) .short('s') .long("start-pause") - .help("to specify the pause time at the start of the animation, that time the gif will show the first frame"), + .help("Specify the pause time at the start of the animation, that time the gif will show the first frame"), ) .arg( Arg::new("file") @@ -109,7 +109,18 @@ pub fn launch() -> ArgMatches { .short('o') .long("output") .default_value("t-rec") - .help("to specify the output file (without extension)"), + .help("Specify the output file (without extension)"), + ) + .arg( + Arg::new("framerate") + .value_name("frames per second") + .takes_value(true) + .required(false) + .short('f') + .long("framerate") + .default_value("4") + // .possible_values(&["4", "8"]) + .help("Increase the screen capturing rate (framerate)"), ) .arg( Arg::new("program") diff --git a/src/generators/gif.rs b/src/generators/gif.rs index 92f8c61..650007a 100644 --- a/src/generators/gif.rs +++ b/src/generators/gif.rs @@ -1,6 +1,6 @@ use crate::utils::{file_name_for, IMG_EXT}; -use anyhow::{Context, Result}; +use anyhow::{bail, Context, Result}; use std::ops::Div; use std::process::{Command, Output}; use std::time::Duration; @@ -38,7 +38,12 @@ pub fn generate_gif_with_convert( cmd.arg("-loop").arg("0"); let mut delay = 0; let temp = tempdir.path(); - let last_frame_i = time_codes.len() - 1; + let last_frame_i = time_codes.last(); + if last_frame_i.is_none() { + // houston we have a problem + bail!("We got no frames :("); + } + let last_frame_i = *last_frame_i.unwrap(); for (i, tc) in time_codes.iter().enumerate() { delay = *tc - delay; let frame = temp.join(file_name_for(tc, IMG_EXT)); @@ -50,7 +55,7 @@ pub fn generate_gif_with_convert( (0, Some(delay), _) => { frame_delay += delay.as_millis().div(10) as u64; } - (i, _, Some(delay)) if i == last_frame_i => { + (i, _, Some(delay)) if i as u128 == last_frame_i => { frame_delay += delay.as_millis().div(10) as u64; } (_, _, _) => {} diff --git a/src/main.rs b/src/main.rs index adfc24f..4cd8fec 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,15 +1,15 @@ +mod capture; mod cli; mod common; mod decor_effect; mod generators; mod tips; +mod utils; -mod capture; #[cfg(target_os = "linux")] mod linux; #[cfg(target_os = "macos")] mod macos; -mod utils; #[cfg(target_os = "windows")] mod win; @@ -27,13 +27,13 @@ use crate::decor_effect::{apply_big_sur_corner_effect, apply_shadow_effect}; use crate::generators::{check_for_gif, check_for_mp4, generate_gif, generate_mp4}; use crate::tips::show_tip; -use crate::capture::capture_thread; +use crate::capture::{capture_thread, FrameDropStrategy, Framerate}; use crate::utils::{sub_shell_thread, target_file, DEFAULT_EXT, MOVIE_EXT}; use anyhow::{bail, Context}; use clap::ArgMatches; use image::FlatSamples; use std::borrow::Borrow; -use std::sync::{mpsc, Arc, Mutex}; +use std::sync::{Arc, Mutex}; use std::time::{Duration, Instant}; use std::{env, thread}; use tempfile::TempDir; @@ -64,7 +64,8 @@ fn main() -> Result<()> { if args.is_present("list-windows") { return ls_win(); } - + let quiet = args.is_present("quiet"); + let verbose = args.is_present("verbose"); let program: String = { if args.is_present("program") { args.value_of("program").unwrap().to_owned() @@ -77,13 +78,22 @@ fn main() -> Result<()> { let mut api = setup()?; api.calibrate(win_id)?; - let force_natural = args.is_present("natural-mode"); + let frame_drop_strategy = args + .is_present("natural-mode") + .then(|| FrameDropStrategy::DoNotDropAny) + .unwrap_or_else(|| FrameDropStrategy::DropIdenticalFrames); let should_generate_gif = !args.is_present("video-only"); let should_generate_video = args.is_present("video") || args.is_present("video-only"); let (start_delay, end_delay) = ( parse_delay(args.value_of("start-pause"), "start-pause")?, parse_delay(args.value_of("end-pause"), "end-pause")?, ); + let framerate = args + .value_of("framerate") + .unwrap() + .parse::() + .map(|f| Framerate::new(f)) + .context("Invalid value for framerate")?; if should_generate_gif { check_for_gif()?; @@ -93,34 +103,38 @@ fn main() -> Result<()> { } // the nice thing is the cleanup on drop - let tempdir = Arc::new(Mutex::new( - TempDir::new().context("Cannot create tempdir.")?, - )); + let tempdir = Arc::new(TempDir::new().context("Cannot create tempdir.")?); let time_codes = Arc::new(Mutex::new(Vec::new())); - let (tx, rx) = mpsc::channel(); + let (tx, rx) = crossbeam_channel::bounded(1); let photograph = { let tempdir = tempdir.clone(); let time_codes = time_codes.clone(); - let force_natural = force_natural; + let framerate = framerate.clone(); thread::spawn(move || -> Result<()> { - capture_thread(&rx, api, win_id, time_codes, tempdir, force_natural) + capture_thread( + &rx, + api, + win_id, + time_codes, + tempdir, + &frame_drop_strategy, + &framerate, + ) }) }; let interact = thread::spawn(move || -> Result<()> { sub_shell_thread(&program).map(|_| ()) }); clear_screen(); - if args.is_present("verbose") { - println!( - "Frame cache dir: {:?}", - tempdir.lock().expect("Cannot lock tempdir resource").path() - ); + if verbose { + println!("Frame cache dir: {:?}", tempdir.path()); + let fr = format!(" @ {}", &framerate); if let Some(window) = window_name { - println!("Recording window: {:?}", window); + println!("Recording window: {window}{fr}"); } else { - println!("Recording window id: {}", win_id); + println!("Recording window id: {win_id}{fr}"); } } - if args.is_present("quiet") { + if quiet { println!(); } else { println!("[t-rec]: Press Ctrl+D to end recording"); @@ -145,15 +159,12 @@ fn main() -> Result<()> { ); show_tip(); - apply_big_sur_corner_effect( - &time_codes.lock().unwrap(), - tempdir.lock().unwrap().borrow(), - ); + apply_big_sur_corner_effect(&time_codes.lock().unwrap(), tempdir.borrow()); if let Some("shadow") = args.value_of("decor") { apply_shadow_effect( &time_codes.lock().unwrap(), - tempdir.lock().unwrap().borrow(), + tempdir.borrow(), args.value_of("bg").unwrap().to_string(), ) } @@ -165,7 +176,7 @@ fn main() -> Result<()> { time += prof! { generate_gif( &time_codes.lock().unwrap(), - tempdir.lock().unwrap().borrow(), + tempdir.borrow(), &format!("{}.{}", target, DEFAULT_EXT), start_delay, end_delay @@ -177,7 +188,7 @@ fn main() -> Result<()> { time += prof! { generate_mp4( &time_codes.lock().unwrap(), - tempdir.lock().unwrap().borrow(), + tempdir.borrow(), &format!("{}.{}", target, MOVIE_EXT), )?; } diff --git a/src/tips.rs b/src/tips.rs index 011154b..3343b5f 100644 --- a/src/tips.rs +++ b/src/tips.rs @@ -6,6 +6,7 @@ const TIPS: &[&str] = &[ "To add a pause at the beginning of the gif loop, use e.g. option `-s 500ms` option", "To prevent cutting out stall frames, checkout the `-n` option", "To remove the shadow around the gif, use the `-d none` option", + "To double the capturing framerate, use the option `-f 8`", "For a mp4 video, use the `-m` option", "To suppress the 'Ctrl+D' banner, use the `-q` option", ];