diff --git a/README.md b/README.md index 9fd20f1..752298a 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ sudo port install t-rec **NOTE** `t-rec` depends on `imagemagick`. ```sh brew install imagemagick -cargo install -f t-rec +cargo install -f t-rec ``` **NOTE** `-f` just makes sure the latest version is installed @@ -113,11 +113,11 @@ cargo install -f t-rec |-------------------------| | ubuntu 20.10 on GNOME | | ![demo-ubuntu](./docs/demo-ubuntu.gif) | -| ubuntu 20.10 on i3wm | +| ubuntu 20.10 on i3wm | | ![demo-ubuntu-i3wm](./docs/demo-ubuntu-i3wm.gif) | -| linux mint 20 on cinnamon | +| linux mint 20 on cinnamon | | ![demo-mint](./docs/demo-mint.gif) | -| ArcoLinux 5.4 on Xfwm4 | +| ArcoLinux 5.4 on Xfwm4 | | ![demo-arco](./docs/demo-arco-xfwm4.gif) | ## Usage @@ -150,7 +150,7 @@ Options: 'Press Ctrl+D to end recording' -m, --video Generates additionally to the gif a mp4 video of the recording -M, --video-only Generates only a mp4 video and not gif - -d, --decor Decorates the animation with certain, mostly border effects + -d, --decor Decorates the animation with certain, mostly border effects [default: none] [possible values: shadow, none] -b, --bg Background color when decors are used [default: transparent] [possible values: white, black, transparent] @@ -165,6 +165,8 @@ Options: the gif will show the last frame -s, --start-pause to specify the pause time at the start of the animation, that time the gif will show the first frame + -i, --idle-pause to preserve natural pauses up to a maximum duration by overriding + idle detection. Can enhance readability. -o, --output to specify the output file (without extension) [default: t-rec] -h, --help Print help -V, --version Print version @@ -173,12 +175,19 @@ Options: ### Disable idle detection & optimization If you are not happy with the idle detection and optimization, you can disable it with the `-n` or `--natural` parameter. -By doing so, you would get the very natural timeline of typing and recording as you do it. +By doing so, you would get the very natural timeline of typing and recording as you do it. In this case there will be no optimizations performed. +Alternatively, you can keep recording idle time before optimization kicks in with the `--idle-pause` parameter. +This gives viewers time to read the text on screen before the animation jumps to the next change: +```sh +t-rec --idle-pause 1s # Show 1 second of unchanged content before optimization +t-rec --idle-pause 500ms # Show 500ms of idle time +``` + ### Enable shadow border decor -In order to enable the drop shadow border decor you have to pass `-d shadow` as an argument. If you only want to change +In order to enable the drop shadow border decor you have to pass `-d shadow` as an argument. If you only want to change the color of the background you can use `-b black` for example to have a black background. ### Record Arbitrary windows @@ -190,7 +199,7 @@ You can record not only the terminal but also every other window. There 3 ways t t-rec --ls-win | grep -i calc Calculator | 45007 -t-rec -w 45007 +t-rec -w 45007 ``` 2) use the env var `TERM_PROGRAM` like this: @@ -210,7 +219,7 @@ this is how it looks then: 3) use the env var `WINDOWID` like this: - for example let's record a `VSCode` window -- figure out the window id program, and make it +- figure out the window id program, and make it - make sure the window is visible on screen - set the variable and run `t-rec` diff --git a/src/capture.rs b/src/capture.rs index 01fcf3c..9356037 100644 --- a/src/capture.rs +++ b/src/capture.rs @@ -11,9 +11,29 @@ 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 +/// Captures screenshots periodically and decides which frames to keep. +/// +/// Eliminates long idle periods while preserving brief pauses that aid +/// viewer comprehension. Adjusts timestamps to prevent playback gaps. +/// +/// # Parameters +/// * `rx` - Channel to receive stop signal +/// * `api` - Platform API for taking screenshots +/// * `win_id` - Window ID to capture +/// * `time_codes` - Shared list to store frame timestamps +/// * `tempdir` - Directory for saving frames +/// * `force_natural` - If true, save all frames (no skipping) +/// * `idle_pause` - Maximum pause duration to preserve for viewer comprehension: +/// - `None`: Skip all identical frames (maximum compression) +/// - `Some(duration)`: Preserve pauses up to this duration, skip beyond +/// +/// # Behavior +/// When identical frames are detected: +/// - Within threshold: frames are saved (preserves brief pauses) +/// - Beyond threshold: frames are skipped and time is subtracted from timestamps +/// +/// Example: 10-second idle with 3-second threshold → saves 3 seconds of pause, +/// skips 7 seconds, playback shows exactly 3 seconds. pub fn capture_thread( rx: &Receiver<()>, api: impl PlatformApi, @@ -21,12 +41,21 @@ pub fn capture_thread( time_codes: Arc>>, tempdir: Arc>, force_natural: bool, + idle_pause: Option, ) -> Result<()> { - let duration = Duration::from_millis(250); + #[cfg(test)] + let duration = Duration::from_millis(10); // Fast for testing + #[cfg(not(test))] + let duration = Duration::from_millis(250); // Production speed let start = Instant::now(); + + // Total idle time skipped (subtracted from timestamps to prevent gaps) let mut idle_duration = Duration::from_millis(0); + + // How long current identical frames have lasted + let mut current_idle_period = 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 @@ -34,34 +63,63 @@ pub fn capture_thread( break; } let now = Instant::now(); + + // Calculate timestamp with skipped idle time removed 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; - } + let frame_duration = now.duration_since(last_now); + + // Check if frame is identical to previous (skip check in natural mode) + let frame_unchanged = !force_natural + && last_frame + .as_ref() + .map(|last| image.samples.as_slice() == last.samples.as_slice()) + .unwrap_or(false); + + // Track duration of identical frames + if frame_unchanged { + current_idle_period = current_idle_period.add(frame_duration); + } else { + current_idle_period = Duration::from_millis(0); } - if identical_frames > 0 { - // let's track now the duration as idle - idle_duration = idle_duration.add(now.duration_since(last_now)); + // Decide whether to save this frame + let should_save_frame = if frame_unchanged { + let should_skip_for_compression = if let Some(threshold) = idle_pause { + // Skip if idle exceeds threshold + current_idle_period >= threshold + } else { + // No threshold: skip all identical frames + true + }; + + if should_skip_for_compression { + // Add skipped time to idle_duration for timestamp adjustment + idle_duration = idle_duration.add(frame_duration); + false + } else { + // Save frame (idle within threshold) + true + } } else { + // Frame changed: reset idle tracking and save + current_idle_period = Duration::from_millis(0); + true + }; + + if should_save_frame { + // Save frame and update state 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); + + // Store frame for next comparison last_frame = Some(image); - identical_frames = 0; } last_now = now; } @@ -69,7 +127,7 @@ pub fn capture_thread( Ok(()) } -/// saves a frame as a tga file +/// Saves a frame as a BMP file. pub fn save_frame( image: &ImageOnHeap, time_code: u128, @@ -85,3 +143,279 @@ pub fn save_frame( ) .context("Cannot save frame") } + +#[cfg(test)] +mod tests { + use super::*; + use std::sync::mpsc; + use tempfile::TempDir; + + /// Mock PlatformApi that returns predefined 1x1 pixel frames. + /// After all frames are used, keeps returning the last frame. + struct TestApi { + frames: Vec>, + index: std::cell::Cell, + } + + impl crate::PlatformApi for TestApi { + fn capture_window_screenshot( + &self, + _: crate::WindowId, + ) -> crate::Result { + let i = self.index.get(); + self.index.set(i + 1); + // Return 1x1 RGBA pixel data - stop at last frame instead of cycling + let num_channels = 4; // RGBA + let pixel_width = 1; + let pixel_height = 1; + let frame_index = if i >= self.frames.len() { + self.frames.len() - 1 // Stay on last frame + } else { + i + }; + Ok(Box::new(image::FlatSamples { + samples: self.frames[frame_index].clone(), + layout: image::flat::SampleLayout::row_major_packed( + num_channels, + pixel_width, + pixel_height, + ), + color_hint: Some(image::ColorType::Rgba8), + })) + } + fn calibrate(&mut self, _: crate::WindowId) -> crate::Result<()> { + Ok(()) + } + fn window_list(&self) -> crate::Result { + Ok(vec![]) + } + fn get_active_window(&self) -> crate::Result { + Ok(0) + } + } + + /// Converts byte array to frame data for testing. + /// Each byte becomes all 4 channels of an RGBA pixel. + /// Same values = identical frames, different values = changed content. + /// + /// Example: frames(&[1,2,2,3]) creates 4 frames where frames 1 and 2 are identical + fn frames>(sequence: T) -> Vec> { + sequence + .as_ref() + .iter() + .map(|&value| vec![value; 4]) + .collect() + } + + /// Runs capture_thread with test frames and returns timestamps of saved frames. + fn run_capture_test( + test_frames: Vec>, + natural_mode: bool, + idle_threshold: Option, + ) -> crate::Result> { + let test_api = TestApi { + frames: test_frames.clone(), + index: Default::default(), + }; + let captured_timestamps = Arc::new(Mutex::new(Vec::new())); + let temp_directory = Arc::new(Mutex::new(TempDir::new()?)); + let (stop_signal_tx, stop_signal_rx) = mpsc::channel(); + + // Run capture for (frame_count * 10ms) + 15ms buffer + let frame_interval = 10; // ms per frame in test mode + let capture_duration_ms = (test_frames.len() as u64 * frame_interval) + 15; + + std::thread::spawn(move || { + std::thread::sleep(Duration::from_millis(capture_duration_ms)); + let _ = stop_signal_tx.send(()); + }); + + let timestamps_clone = captured_timestamps.clone(); + capture_thread( + &stop_signal_rx, + test_api, + 0, + timestamps_clone, + temp_directory, + natural_mode, + idle_threshold, + )?; + let result = captured_timestamps.lock().unwrap().clone(); + Ok(result) + } + + /// Analyzes captured frame timestamps to verify compression worked correctly. + /// + /// Returns a tuple of: + /// - Frame count: Total number of frames captured + /// - Total duration: Time span from first to last frame (ms) + /// - Has gaps: Whether gaps over 25ms exist that indicate compression failure + /// + /// Gaps over 25ms between consecutive frames indicate the timeline + /// compression algorithm failed to maintain continuous playback. + fn analyze_timeline(timestamps: &[u128]) -> (usize, u128, bool) { + let max_normal_gap = 25; // Maximum expected gap between consecutive frames (ms) + + let frame_count = timestamps.len(); + let total_duration_ms = if timestamps.len() > 1 { + timestamps.last().unwrap() - timestamps.first().unwrap() + } else { + 0 + }; + + // Detect gaps over 25ms indicating timeline compression failure + let has_compression_gaps = timestamps + .windows(2) + .any(|window| window[1] - window[0] > max_normal_gap); + + (frame_count, total_duration_ms, has_compression_gaps) + } + + /// Tests idle frame compression behavior. + /// + /// Verifies: + /// - Correct frame count based on threshold settings + /// - No timestamp gaps over 25ms after compression (ensures smooth playback) + /// - Natural mode saves all frames regardless of content + /// - Threshold boundaries work correctly (e.g., exactly at 30ms) + #[test] + fn test_idle_pause() -> crate::Result<()> { + // Test format: (frames, natural_mode, threshold_ms, expected_count, description) + // - frames: byte array where same value = identical frame + // - natural_mode: true = save all, false = skip identical + // - threshold_ms: None = skip all identical, Some(n) = keep up to n ms + // - expected_count: range due to timing variations + // - [..] converts array to slice (required for different array sizes) + // + // Example: [1,2,2,2,3] = active frame, 3 idle frames, then active frame + [ + // Natural mode - saves all frames regardless of content + ( + &[1, 1, 1][..], + true, + None, + 3..=4, + "natural mode preserves all frames", + ), + // Basic single frame test + (&[1][..], false, None, 1..=2, "single frame recording"), + // All different frames - no idle to compress + ( + &[1, 2, 3][..], + false, + None, + 3..=3, + "all different frames saved", + ), + // Basic idle compression + ( + &[1, 1, 1][..], + false, + None, + 1..=1, + "3 identical frames → 1 frame", + ), + // Long threshold preserves short sequences + ( + &[1, 1, 1][..], + false, + Some(500), + 3..=4, + "500ms threshold preserves 30ms idle", + ), + // Multiple idle periods compress independently + ( + &[1, 2, 2, 2, 3, 4, 4, 4][..], + false, + None, + 3..=4, + "two idle periods compress independently", + ), + // 20ms threshold behavior + ( + &[1, 2, 2, 2, 3, 4, 4, 4][..], + false, + Some(20), + 6..=8, + "20ms threshold: 2 frames per idle period", + ), + // Mixed idle lengths with 30ms threshold + ( + &[1, 2, 2, 3, 4, 5, 5, 5, 5][..], + false, + Some(30), + 8..=9, + "mixed idle: 20ms saved, 40ms partial", + ), + // Content change resets idle tracking + ( + &[1, 2, 2, 3, 4, 4, 4, 5][..], + false, + Some(25), + 6..=8, + "content change resets idle tracking", + ), + // Exact threshold boundary + ( + &[1, 2, 2, 2, 3][..], + false, + Some(30), + 5..=6, + "exact 30ms boundary test", + ), + // Timeline compression verification + ( + &[1, 2, 2, 2, 2, 3][..], + false, + Some(20), + 4..=4, + "40ms idle: 20ms saved, rest compressed", + ), + // Maximum compression + ( + &[1, 2, 2, 2, 2, 3][..], + false, + None, + 2..=3, + "max compression: only active frames", + ), + ] + .iter() + .enumerate() + .try_for_each(|(i, (frame_seq, natural, threshold_ms, expected, desc))| { + let threshold = threshold_ms.map(Duration::from_millis); + let timestamps = run_capture_test(frames(frame_seq), *natural, threshold)?; + let (count, duration, has_gaps) = analyze_timeline(×tamps); + + // Check frame count matches expectation + assert!( + expected.contains(&count), + "Test {}: expected {:?} frames, got {}", + i + 1, + expected, + count + ); + + // Check timeline compression (no gaps over 25ms between frames) + if threshold.is_some() && !natural { + assert!(!has_gaps, "Test {}: timeline has gaps", i + 1); + } + + // Check aggressive compression for long idle sequences + if !natural + && threshold.is_none() + && frame_seq.windows(2).filter(|w| w[0] == w[1]).count() >= 3 + { + assert!( + duration < 120, + "Test {}: duration {} too long", + i + 1, + duration + ); + } + + println!("✓ Test {}: {} - {} frames captured", i + 1, desc, count); + Ok(()) + }) + } +} diff --git a/src/cli.rs b/src/cli.rs index 3e6e132..eb8c75e 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -103,6 +103,15 @@ pub fn launch() -> ArgMatches { .long("start-pause") .help("to specify the pause time at the start of the animation, that time the gif will show the first frame"), ) + .arg( + Arg::new("idle-pause") + .value_parser(NonEmptyStringValueParser::new()) + .value_name("s | ms | m") + .required(false) + .short('i') + .long("idle-pause") + .help("to preserve natural pauses up to a maximum duration by overriding idle detection. Can enhance readability."), + ) .arg( Arg::new("file") .value_parser(NonEmptyStringValueParser::new()) diff --git a/src/main.rs b/src/main.rs index ab0d718..c2f613c 100644 --- a/src/main.rs +++ b/src/main.rs @@ -81,9 +81,10 @@ fn main() -> Result<()> { let force_natural = args.get_flag("natural-mode"); let should_generate_gif = !args.get_flag("video-only"); let should_generate_video = args.get_flag("video") || args.get_flag("video-only"); - let (start_delay, end_delay) = ( + let (start_delay, end_delay, idle_pause) = ( parse_delay(args.get_one::("start-pause"), "start-pause")?, parse_delay(args.get_one::("end-pause"), "end-pause")?, + parse_delay(args.get_one::("idle-pause"), "idle-pause")?, ); if should_generate_gif { @@ -103,7 +104,15 @@ fn main() -> Result<()> { let tempdir = tempdir.clone(); let time_codes = time_codes.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, + force_natural, + idle_pause, + ) }) }; let interact = thread::spawn(move || -> Result<()> { sub_shell_thread(&program).map(|_| ()) });