use anyhow::{Context, Result};
use image::save_buffer;
use image::ColorType::Rgba8;
#[cfg(feature = "cli")]
use log::{debug, error};
use std::borrow::Borrow;
use std::ops::{Add, Sub};
use std::sync::{Arc, Mutex};
use std::time::{Duration, Instant};
use tempfile::TempDir;
use tokio::sync::broadcast::error::TryRecvError;
use tokio::sync::broadcast::Receiver;
#[cfg(feature = "cli")]
use super::event_router::LifecycleEvent;
use super::event_router::{CaptureEvent, Event};
#[cfg(feature = "cli")]
use super::screenshot::screenshot_file_name;
#[cfg(feature = "cli")]
use super::screenshot::ScreenshotInfo;
use super::utils::{file_name_for, IMG_EXT};
use super::{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,
#[cfg(feature = "cli")]
pub screenshots: Option<Arc<Mutex<Vec<ScreenshotInfo>>>>,
}
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(
mut rx: Receiver<Event>,
api: impl PlatformApi,
ctx: CaptureContext,
) -> Result<()> {
#[cfg(feature = "cli")]
loop {
match rx.blocking_recv() {
Ok(Event::Capture(CaptureEvent::Start)) => break,
Ok(Event::Capture(CaptureEvent::Stop))
| Ok(Event::Lifecycle(LifecycleEvent::Shutdown)) => return Ok(()),
Ok(_) => continue, Err(_) => return Ok(()),
}
}
#[cfg(not(feature = "cli"))]
match rx.blocking_recv() {
Ok(Event::Capture(CaptureEvent::Start)) => {}
Ok(Event::Capture(CaptureEvent::Stop)) | Err(_) => return Ok(()),
}
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 {
let elapsed = last_now.elapsed();
if let Some(remaining) = duration.checked_sub(elapsed) {
std::thread::sleep(remaining);
}
#[cfg(feature = "cli")]
let screenshot_event_tc = match rx.try_recv() {
Ok(Event::Capture(CaptureEvent::Stop))
| Ok(Event::Lifecycle(LifecycleEvent::Shutdown)) => break,
Ok(Event::Capture(CaptureEvent::Start)) => continue,
Ok(Event::Capture(CaptureEvent::Screenshot { timecode_ms })) => {
debug!("Received Screenshot event with timecode {}", timecode_ms);
Some(timecode_ms)
}
Ok(_) => None, Err(TryRecvError::Closed) => break,
Err(TryRecvError::Empty) => None,
Err(_) => None,
};
#[cfg(not(feature = "cli"))]
let screenshot_event_tc: Option<u128> = match rx.try_recv() {
Ok(Event::Capture(CaptureEvent::Stop)) => break,
Ok(Event::Capture(CaptureEvent::Start)) => continue,
Err(TryRecvError::Closed) => break,
Err(TryRecvError::Empty) => None,
Err(_) => None,
};
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);
#[cfg(feature = "cli")]
if let Some(screenshot_tc) = screenshot_event_tc {
debug!("Taking screenshot at tc={}", screenshot_tc);
if let Err(e) = save_screenshot(&image, screenshot_tc, &ctx) {
error!("Failed to save screenshot: {}", e);
} else {
debug!("Screenshot saved successfully to tempdir");
}
}
#[cfg(not(feature = "cli"))]
let _ = screenshot_event_tc;
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(())
}
#[cfg(feature = "cli")]
fn save_screenshot(image: &ImageOnHeap, timecode_ms: u128, ctx: &CaptureContext) -> Result<()> {
let tempdir = ctx.tempdir.lock().unwrap();
let path = tempdir
.path()
.join(screenshot_file_name(timecode_ms, IMG_EXT));
save_buffer(
&path,
&image.samples,
image.layout.width,
image.layout.height,
image.color_hint.unwrap_or(Rgba8),
)
.context("Cannot save screenshot")?;
debug!("Screenshot saved at timecode {timecode_ms}");
if let Some(ref screenshots) = ctx.screenshots {
screenshots.try_lock().unwrap().push(ScreenshotInfo {
timecode_ms,
temp_path: path.clone(),
});
debug!("ScreenshotInfo collected for timecode {timecode_ms}");
} else {
debug!("ScreenshotInfo collection skipped (no storage) for timecode {timecode_ms}");
}
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(all(test, feature = "cli"))]
mod tests {
use super::*;
use tempfile::TempDir;
use tokio::sync::broadcast;
struct TestApi {
frames: Vec<Vec<u8>>,
index: std::cell::Cell<usize>,
stop_sender: tokio::sync::broadcast::Sender<Event>,
}
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 {
if i == self.frames.len() - 1 {
let _ = self.stop_sender.send(Event::Capture(CaptureEvent::Stop));
}
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 captured_timestamps = Arc::new(Mutex::new(Vec::new()));
let temp_directory = Arc::new(Mutex::new(TempDir::new()?));
let (tx, rx) = broadcast::channel::<Event>(10);
let test_api = TestApi {
frames: test_frames.clone(),
index: Default::default(),
stop_sender: tx.clone(),
};
tx.send(Event::Capture(CaptureEvent::Start)).unwrap();
let ctx = CaptureContext {
win_id: 0,
time_codes: captured_timestamps.clone(),
tempdir: temp_directory,
natural: natural_mode,
idle_pause: idle_threshold,
fps: 4,
screenshots: None,
};
capture_thread(rx, test_api, ctx)?;
let result = captured_timestamps.lock().unwrap().clone();
Ok(result)
}
#[test]
fn test_all_unique_frames_are_captured() {
let test_frames = frames([1, 2, 3, 4, 5]);
let timestamps = run_capture_test(test_frames, false, None).unwrap();
assert_eq!(timestamps.len(), 5, "All unique frames should be captured");
}
#[test]
fn test_identical_frames_are_skipped_by_default() {
let test_frames = frames([1, 1, 1, 2, 2, 3]);
let timestamps = run_capture_test(test_frames, false, None).unwrap();
assert_eq!(
timestamps.len(),
3,
"Identical consecutive frames should be skipped"
);
}
#[test]
fn test_natural_mode_preserves_all_frames() {
let test_frames = frames([1, 1, 1, 2, 2, 3]);
let timestamps = run_capture_test(test_frames, true, None).unwrap();
assert_eq!(timestamps.len(), 6, "Natural mode should keep all frames");
}
#[test]
fn test_idle_threshold_preserves_short_pauses() {
let test_frames = frames([1, 1, 1, 2]); let timestamps =
run_capture_test(test_frames, false, Some(Duration::from_millis(500))).unwrap();
assert!(
timestamps.len() >= 2,
"Frames within idle threshold should be preserved, got {} frames",
timestamps.len()
);
}
#[test]
fn test_idle_threshold_skips_long_pauses() {
let test_frames = frames([1, 1, 1, 1, 1, 1, 1, 1, 2]); let timestamps =
run_capture_test(test_frames, false, Some(Duration::from_millis(25))).unwrap();
assert!(
timestamps.len() < 9,
"Long idle periods beyond threshold should be skipped"
);
assert!(
timestamps.len() >= 2,
"Unique frames should still be captured"
);
}
#[test]
fn test_alternating_frames_all_captured() {
let test_frames = frames([1, 2, 1, 2, 1]);
let timestamps = run_capture_test(test_frames, false, None).unwrap();
assert_eq!(
timestamps.len(),
5,
"Alternating frames should all be captured"
);
}
#[test]
fn test_timestamps_are_monotonically_increasing() {
let test_frames = frames([1, 2, 3, 4, 5]);
let timestamps = run_capture_test(test_frames, false, None).unwrap();
for window in timestamps.windows(2) {
assert!(
window[1] > window[0],
"Timestamps should be strictly increasing"
);
}
}
#[test]
fn test_stop_event_terminates_capture() {
let captured_timestamps = Arc::new(Mutex::new(Vec::new()));
let temp_directory = Arc::new(Mutex::new(TempDir::new().unwrap()));
let (tx, rx) = broadcast::channel::<Event>(10);
let test_api = TestApi {
frames: frames([1, 2, 3]),
index: Default::default(),
stop_sender: tx.clone(),
};
tx.send(Event::Capture(CaptureEvent::Start)).unwrap();
tx.send(Event::Capture(CaptureEvent::Stop)).unwrap();
let ctx = CaptureContext {
win_id: 0,
time_codes: captured_timestamps.clone(),
tempdir: temp_directory,
natural: false,
idle_pause: None,
fps: 4,
screenshots: None,
};
capture_thread(rx, test_api, ctx).unwrap();
}
#[test]
fn test_shutdown_event_terminates_capture() {
let captured_timestamps = Arc::new(Mutex::new(Vec::new()));
let temp_directory = Arc::new(Mutex::new(TempDir::new().unwrap()));
let (tx, rx) = broadcast::channel::<Event>(10);
let test_api = TestApi {
frames: frames([1, 2, 3]),
index: Default::default(),
stop_sender: tx.clone(),
};
tx.send(Event::Lifecycle(LifecycleEvent::Shutdown)).unwrap();
let ctx = CaptureContext {
win_id: 0,
time_codes: captured_timestamps.clone(),
tempdir: temp_directory,
natural: false,
idle_pause: None,
fps: 4,
screenshots: None,
};
capture_thread(rx, test_api, ctx).unwrap();
}
#[test]
fn test_frames_saved_to_tempdir() {
let test_frames = frames([1, 2, 3]);
let captured_timestamps = Arc::new(Mutex::new(Vec::new()));
let temp_directory = Arc::new(Mutex::new(TempDir::new().unwrap()));
let (tx, rx) = broadcast::channel::<Event>(10);
let temp_directory_check = temp_directory.clone();
let test_api = TestApi {
frames: test_frames,
index: Default::default(),
stop_sender: tx.clone(),
};
tx.send(Event::Capture(CaptureEvent::Start)).unwrap();
let ctx = CaptureContext {
win_id: 0,
time_codes: captured_timestamps.clone(),
tempdir: temp_directory,
natural: false,
idle_pause: None,
fps: 4,
screenshots: None,
};
capture_thread(rx, test_api, ctx).unwrap();
let temp_guard = temp_directory_check.lock().unwrap();
let files: Vec<_> = std::fs::read_dir(temp_guard.path())
.unwrap()
.filter_map(|e| e.ok())
.collect();
let num_timestamps = captured_timestamps.lock().unwrap().len();
assert_eq!(
files.len(),
num_timestamps,
"Number of saved files should match timestamps"
);
}
}