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};
pub struct CaptureContext {
pub win_id: WindowId,
pub time_codes: Arc<Mutex<Vec<u128>>>,
pub tempdir: Arc<Mutex<TempDir>>,
pub natural: bool,
pub idle_pause: Option<Duration>,
pub fps: u8,
}
impl CaptureContext {
pub fn frame_interval(&self) -> Duration {
if cfg!(test) {
Duration::from_millis(10) } else {
Duration::from_millis(1000 / self.fps as u64)
}
}
}
pub fn capture_thread(rx: &Receiver<()>, api: impl PlatformApi, ctx: CaptureContext) -> Result<()> {
let duration = ctx.frame_interval();
let start = Instant::now();
let mut idle_duration = Duration::from_millis(0);
let mut current_idle_period = Duration::from_millis(0);
let mut last_frame: Option<ImageOnHeap> = None;
let mut last_now = Instant::now();
loop {
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(ctx.win_id)?;
let frame_duration = now.duration_since(last_now);
let frame_unchanged = !ctx.natural
&& last_frame
.as_ref()
.map(|last| image.samples.as_slice() == last.samples.as_slice())
.unwrap_or(false);
if frame_unchanged {
current_idle_period = current_idle_period.add(frame_duration);
} else {
current_idle_period = Duration::from_millis(0);
}
let should_save_frame = if frame_unchanged {
let should_skip_for_compression = if let Some(threshold) = ctx.idle_pause {
current_idle_period >= threshold
} else {
true
};
if should_skip_for_compression {
idle_duration = idle_duration.add(frame_duration);
false
} else {
true
}
} else {
current_idle_period = Duration::from_millis(0);
true
};
if should_save_frame {
if let Err(e) = save_frame(
&image,
tc,
ctx.tempdir.lock().unwrap().borrow(),
file_name_for,
) {
eprintln!("{}", &e);
return Err(e);
}
ctx.time_codes.lock().unwrap().push(tc);
last_frame = Some(image);
}
last_now = now;
}
Ok(())
}
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")
}
#[cfg(test)]
mod tests {
use super::*;
use std::sync::mpsc;
use tempfile::TempDir;
struct TestApi {
frames: Vec<Vec<u8>>,
index: std::cell::Cell<usize>,
}
impl crate::PlatformApi for TestApi {
fn capture_window_screenshot(
&self,
_: crate::WindowId,
) -> crate::Result<crate::ImageOnHeap> {
let i = self.index.get();
self.index.set(i + 1);
let num_channels = 4; let pixel_width = 1;
let pixel_height = 1;
let frame_index = if i >= self.frames.len() {
self.frames.len() - 1 } 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<crate::WindowList> {
Ok(vec![])
}
fn get_active_window(&self) -> crate::Result<crate::WindowId> {
Ok(0)
}
}
fn frames<T: AsRef<[u8]>>(sequence: T) -> Vec<Vec<u8>> {
sequence
.as_ref()
.iter()
.map(|&value| vec![value; 4])
.collect()
}
fn run_capture_test(
test_frames: Vec<Vec<u8>>,
natural_mode: bool,
idle_threshold: Option<Duration>,
) -> crate::Result<Vec<u128>> {
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();
let frame_interval = 10; 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 ctx = CaptureContext {
win_id: 0,
time_codes: captured_timestamps.clone(),
tempdir: temp_directory,
natural: natural_mode,
idle_pause: idle_threshold,
fps: 4, };
capture_thread(&stop_signal_rx, test_api, ctx)?;
let result = captured_timestamps.lock().unwrap().clone();
Ok(result)
}
fn analyze_timeline(timestamps: &[u128]) -> (usize, u128, bool) {
let max_normal_gap = 25;
let frame_count = timestamps.len();
let total_duration_ms = if timestamps.len() > 1 {
timestamps.last().unwrap() - timestamps.first().unwrap()
} else {
0
};
let has_compression_gaps = timestamps
.windows(2)
.any(|window| window[1] - window[0] > max_normal_gap);
(frame_count, total_duration_ms, has_compression_gaps)
}
#[test]
#[cfg(feature = "e2e_tests")]
fn test_idle_pause() -> crate::Result<()> {
[
(
vec![1, 1, 1],
true,
None,
3..=4,
"natural mode preserves all frames",
),
(vec![1], false, None, 1..=2, "single frame recording"),
(
vec![1, 2, 3],
false,
None,
3..=3,
"all different frames saved",
),
(
vec![1, 1, 1],
false,
None,
1..=1,
"3 identical frames → 1 frame",
),
(
vec![1, 1, 1],
false,
Some(500),
3..=4,
"500ms threshold preserves 30ms idle",
),
(
vec![1, 2, 2, 2, 3, 4, 4, 4],
false,
None,
3..=4,
"two idle periods compress independently",
),
(
vec![1, 2, 2, 2, 3, 4, 4, 4],
false,
Some(20),
6..=8,
"20ms threshold: 2 frames per idle period",
),
(
vec![1, 2, 2, 3, 4, 5, 5, 5, 5],
false,
Some(30),
8..=9,
"mixed idle: 20ms saved, 40ms partial",
),
(
vec![1, 2, 2, 3, 4, 4, 4, 5],
false,
Some(25),
6..=8,
"content change resets idle tracking",
),
(
vec![1, 2, 2, 2, 3],
false,
Some(30),
5..=6,
"exact 30ms boundary test",
),
(
vec![1, 2, 2, 2, 2, 3],
false,
Some(20),
4..=4,
"40ms idle: 20ms saved, rest compressed",
),
(
vec![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);
assert!(
expected.contains(&count),
"Test {}: expected {:?} frames, got {}",
i + 1,
expected,
count
);
if threshold.is_some() && !natural {
assert!(!has_gaps, "Test {}: timeline has gaps", i + 1);
}
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(())
})
}
}