use crate::core::capture::{capture_thread, CaptureContext};
use crate::core::common::{Platform, PlatformApi, PlatformApiFactory};
use crate::core::event_router::{CaptureEvent, Event, EventRouter};
use crate::core::generators::{check_for_gif, check_for_mp4, generate_gif, generate_mp4};
use crate::core::post_processing::{post_process_effects, PostProcessingOptions};
use crate::core::types::{BackgroundColor, Decor};
use crate::core::utils::{file_name_for, DEFAULT_EXT, IMG_EXT, MOVIE_EXT};
use crate::core::wallpapers::{
types::Wallpaper, validation::load_and_validate_wallpaper, ventura::get_ventura_wallpaper,
};
use crate::core::WindowId;
use anyhow::{bail, Context, Result};
use image::{DynamicImage, GenericImageView};
use std::path::{Path, PathBuf};
use std::sync::{Arc, Mutex};
use std::thread::{self, JoinHandle};
use std::time::Duration;
use tempfile::TempDir;
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct WallpaperConfig {
pub wallpaper: Wallpaper,
pub padding: u32,
}
impl WallpaperConfig {
pub fn new(wallpaper: Wallpaper, padding: u32) -> Self {
Self { wallpaper, padding }
}
}
#[derive(Debug, Clone)]
pub struct HeadlessRecorderConfig {
pub window_id: WindowId,
pub fps: u8,
pub natural: bool,
pub idle_pause: Option<Duration>,
pub start_pause: Option<Duration>,
pub end_pause: Option<Duration>,
pub decor: Decor,
pub bg_color: BackgroundColor,
pub wallpaper: Option<WallpaperConfig>,
pub generate_gif: bool,
pub generate_mp4: bool,
pub gif_path: Option<PathBuf>,
pub mp4_path: Option<PathBuf>,
}
#[derive(Debug, Clone)]
pub struct RecordingOutput {
pub gif_path: Option<PathBuf>,
pub mp4_path: Option<PathBuf>,
pub frame_count: usize,
}
#[derive(Debug, Clone)]
pub struct HeadlessRecorderBuilder {
window_id: Option<WindowId>,
fps: u8,
natural: bool,
idle_pause: Option<Duration>,
start_pause: Option<Duration>,
end_pause: Option<Duration>,
decor: Decor,
bg_color: BackgroundColor,
wallpaper: Option<WallpaperConfig>,
generate_gif: bool,
generate_mp4: bool,
gif_path: Option<PathBuf>,
mp4_path: Option<PathBuf>,
}
impl Default for HeadlessRecorderBuilder {
fn default() -> Self {
Self {
window_id: None,
fps: 15,
natural: false,
idle_pause: None,
start_pause: None,
end_pause: None,
decor: Decor::Shadow,
bg_color: BackgroundColor::Transparent,
wallpaper: None,
generate_gif: false,
generate_mp4: false,
gif_path: None,
mp4_path: None,
}
}
}
impl HeadlessRecorderBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn with_defaults_for_demo(mut self) -> Self {
self.fps = 15;
self.decor = Decor::None;
self.wallpaper(Wallpaper::Ventura, 60)
}
pub fn window_id(mut self, id: WindowId) -> Self {
self.window_id = Some(id);
self
}
pub fn fps(mut self, fps: u8) -> Self {
self.fps = fps.clamp(1, 60);
self
}
pub fn output_gif(mut self, path: impl AsRef<Path>) -> Self {
self.generate_gif = true;
self.gif_path = Some(path.as_ref().to_path_buf());
self
}
pub fn output_mp4(mut self, path: impl AsRef<Path>) -> Self {
self.generate_mp4 = true;
self.mp4_path = Some(path.as_ref().to_path_buf());
self
}
pub fn output_both(mut self, base_path: impl AsRef<Path>) -> Self {
let base = base_path.as_ref();
self.generate_gif = true;
self.generate_mp4 = true;
self.gif_path = Some(base.with_extension(DEFAULT_EXT));
self.mp4_path = Some(base.with_extension(MOVIE_EXT));
self
}
pub fn decor(mut self, decor: Decor) -> Self {
self.decor = decor;
self
}
pub fn bg_color(mut self, color: BackgroundColor) -> Self {
self.bg_color = color;
self
}
pub fn wallpaper(mut self, wallpaper: Wallpaper, padding: u32) -> Self {
self.wallpaper = Some(WallpaperConfig::new(wallpaper, padding));
self
}
pub fn no_wallpaper(mut self) -> Self {
self.wallpaper = None;
self
}
pub fn natural(mut self, natural: bool) -> Self {
self.natural = natural;
self
}
pub fn idle_pause(mut self, duration: Duration) -> Self {
self.idle_pause = Some(duration);
self
}
pub fn start_pause(mut self, duration: Duration) -> Self {
self.start_pause = Some(duration);
self
}
pub fn end_pause(mut self, duration: Duration) -> Self {
self.end_pause = Some(duration);
self
}
pub fn build(self) -> Result<HeadlessRecorder> {
let window_id = self
.window_id
.context("window_id is required for HeadlessRecorder")?;
if !self.generate_gif && !self.generate_mp4 {
bail!("At least one output format must be specified (use output_gif or output_mp4)");
}
if self.generate_gif {
check_for_gif().context("GIF generation requires ImageMagick's 'convert' command")?;
}
if self.generate_mp4 {
check_for_mp4().context("MP4 generation requires ffmpeg with libx264 support")?;
}
let config = HeadlessRecorderConfig {
window_id,
fps: self.fps,
natural: self.natural,
idle_pause: self.idle_pause,
start_pause: self.start_pause,
end_pause: self.end_pause,
decor: self.decor,
bg_color: self.bg_color,
wallpaper: self.wallpaper,
generate_gif: self.generate_gif,
generate_mp4: self.generate_mp4,
gif_path: self.gif_path,
mp4_path: self.mp4_path,
};
HeadlessRecorder::new(config)
}
}
pub struct HeadlessRecorder {
config: HeadlessRecorderConfig,
state: RecorderState,
}
enum RecorderState {
Ready { api: Box<dyn PlatformApi> },
Recording {
router: EventRouter,
capture_handle: JoinHandle<Result<()>>,
tempdir: Arc<Mutex<TempDir>>,
time_codes: Arc<Mutex<Vec<u128>>>,
},
Consumed,
}
impl HeadlessRecorder {
fn new(config: HeadlessRecorderConfig) -> Result<Self> {
let api = Platform::setup()?;
Ok(Self {
config,
state: RecorderState::Ready { api },
})
}
pub fn builder() -> HeadlessRecorderBuilder {
HeadlessRecorderBuilder::new()
}
pub fn start(&mut self) -> Result<()> {
let old_state = std::mem::replace(&mut self.state, RecorderState::Consumed);
let mut api = match old_state {
RecorderState::Ready { api } => api,
RecorderState::Recording { .. } => {
panic!("HeadlessRecorder::start() called while already recording")
}
RecorderState::Consumed => {
panic!("HeadlessRecorder::start() called after stop_and_generate()")
}
};
api.calibrate(self.config.window_id)
.context("Failed to calibrate for window. Is the window visible?")?;
let tempdir = Arc::new(Mutex::new(
TempDir::new().expect("Failed to create temp directory"),
));
let time_codes = Arc::new(Mutex::new(Vec::new()));
let router = EventRouter::new();
let ctx = CaptureContext {
win_id: self.config.window_id,
time_codes: time_codes.clone(),
tempdir: tempdir.clone(),
natural: self.config.natural,
idle_pause: self.config.idle_pause,
fps: self.config.fps,
#[cfg(feature = "cli")]
screenshots: None,
};
let event_rx = router.subscribe();
let capture_handle = thread::spawn(move || capture_thread(event_rx, api, ctx));
router.send(Event::Capture(CaptureEvent::Start));
self.state = RecorderState::Recording {
router,
capture_handle,
tempdir,
time_codes,
};
Ok(())
}
pub fn stop_and_generate(mut self) -> Result<RecordingOutput> {
let old_state = std::mem::replace(&mut self.state, RecorderState::Consumed);
let (router, capture_handle, tempdir, time_codes) = match old_state {
RecorderState::Recording {
router,
capture_handle,
tempdir,
time_codes,
} => (router, capture_handle, tempdir, time_codes),
RecorderState::Ready { .. } => {
bail!("HeadlessRecorder::stop_and_generate() called before start()")
}
RecorderState::Consumed => {
bail!("HeadlessRecorder::stop_and_generate() called twice")
}
};
router.send(Event::Capture(CaptureEvent::Stop));
capture_handle
.join()
.map_err(|_| anyhow::anyhow!("Capture thread panicked"))?
.context("Capture thread failed")?;
let time_codes_vec = time_codes.lock().unwrap().clone();
let tempdir_guard = tempdir.lock().unwrap();
let frame_count = time_codes_vec.len();
if frame_count == 0 {
bail!("No frames were captured");
}
let frame_files: Vec<PathBuf> = time_codes_vec
.iter()
.map(|tc| tempdir_guard.path().join(file_name_for(tc, IMG_EXT)))
.filter(|p| p.exists())
.collect();
let wallpaper: Option<DynamicImage> = if let Some(ref wp_config) = self.config.wallpaper {
Some(self.load_wallpaper(&wp_config.wallpaper, &frame_files, wp_config.padding)?)
} else {
None
};
let post_opts = if let Some(ref wp) = wallpaper {
let padding = self
.config
.wallpaper
.as_ref()
.map(|c| c.padding)
.unwrap_or(0);
PostProcessingOptions::new(self.config.decor, &self.config.bg_color)
.with_wallpaper(wp, padding)
} else {
PostProcessingOptions::new(self.config.decor, &self.config.bg_color)
};
post_process_effects(&frame_files, &post_opts);
let mut gif_output_path: Option<PathBuf> = None;
let mut mp4_output_path: Option<PathBuf> = None;
if self.config.generate_gif {
if let Some(ref gif_path) = self.config.gif_path {
let gif_path_str = gif_path.to_string_lossy().to_string();
generate_gif(
&time_codes_vec,
&tempdir_guard,
&gif_path_str,
self.config.start_pause,
self.config.end_pause,
)
.context("Failed to generate GIF")?;
gif_output_path = Some(gif_path.clone());
}
}
if self.config.generate_mp4 {
if let Some(ref mp4_path) = self.config.mp4_path {
let mp4_path_str = mp4_path.to_string_lossy().to_string();
generate_mp4(
&time_codes_vec,
&tempdir_guard,
&mp4_path_str,
self.config.fps,
)
.context("Failed to generate MP4")?;
mp4_output_path = Some(mp4_path.clone());
}
}
Ok(RecordingOutput {
gif_path: gif_output_path,
mp4_path: mp4_output_path,
frame_count,
})
}
fn load_wallpaper(
&self,
wallpaper: &Wallpaper,
frame_files: &[PathBuf],
padding: u32,
) -> Result<DynamicImage> {
let (frame_width, frame_height) = if let Some(first_frame) = frame_files.first() {
let img = image::open(first_frame)
.context("Failed to open first frame to determine dimensions")?;
img.dimensions()
} else {
bail!("No frames available to determine dimensions for wallpaper validation");
};
match wallpaper {
Wallpaper::Ventura => {
let wp = get_ventura_wallpaper();
let (wp_width, wp_height) = wp.dimensions();
let min_width = frame_width + (padding * 2);
let min_height = frame_height + (padding * 2);
if wp_width < min_width || wp_height < min_height {
bail!(
"Frame size {}x{} with {}px padding exceeds built-in wallpaper size {}x{}.\n\
Try reducing the frame size or padding.",
frame_width,
frame_height,
padding,
wp_width,
wp_height
);
}
Ok(wp.clone())
}
Wallpaper::Custom(validated_path) => {
load_and_validate_wallpaper(
validated_path.as_path(),
frame_width,
frame_height,
padding,
)
}
}
}
pub fn is_recording(&self) -> bool {
matches!(self.state, RecorderState::Recording { .. })
}
pub fn window_id(&self) -> WindowId {
self.config.window_id
}
pub fn fps(&self) -> u8 {
self.config.fps
}
pub fn gif_path(&self) -> Option<&Path> {
self.config.gif_path.as_deref()
}
pub fn mp4_path(&self) -> Option<&Path> {
self.config.mp4_path.as_deref()
}
pub fn decor(&self) -> Decor {
self.config.decor
}
pub fn bg_color(&self) -> &BackgroundColor {
&self.config.bg_color
}
pub fn wallpaper_config(&self) -> Option<&WallpaperConfig> {
self.config.wallpaper.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_builder_requires_window_id() {
let result = HeadlessRecorderBuilder::new()
.fps(30)
.output_gif("test.gif")
.build();
assert!(result.is_err());
let err = result.err().unwrap();
assert!(err.to_string().contains("window_id is required"));
}
#[test]
fn test_builder_requires_output_format() {
let result = HeadlessRecorderBuilder::new()
.window_id(12345)
.fps(30)
.build();
assert!(result.is_err());
let err = result.err().unwrap();
assert!(err
.to_string()
.contains("At least one output format must be specified"));
}
#[test]
fn test_builder_fps_clamping() {
let builder = HeadlessRecorderBuilder::new().fps(100);
assert_eq!(builder.fps, 60);
let builder = HeadlessRecorderBuilder::new().fps(0);
assert_eq!(builder.fps, 1);
}
#[test]
fn test_builder_default_fps() {
let builder = HeadlessRecorderBuilder::new();
assert_eq!(builder.fps, 15);
}
#[test]
fn test_builder_default_decor() {
let builder = HeadlessRecorderBuilder::new();
assert_eq!(builder.decor, Decor::Shadow);
}
#[test]
fn test_builder_default_bg_color() {
let builder = HeadlessRecorderBuilder::new();
assert_eq!(builder.bg_color, BackgroundColor::Transparent);
}
#[test]
fn test_builder_decor() {
let builder = HeadlessRecorderBuilder::new().decor(Decor::None);
assert_eq!(builder.decor, Decor::None);
let builder = HeadlessRecorderBuilder::new().decor(Decor::Shadow);
assert_eq!(builder.decor, Decor::Shadow);
}
#[test]
fn test_builder_bg_color() {
let builder = HeadlessRecorderBuilder::new().bg_color(BackgroundColor::White);
assert_eq!(builder.bg_color, BackgroundColor::White);
let builder =
HeadlessRecorderBuilder::new().bg_color(BackgroundColor::custom("#ff5500").unwrap());
assert_eq!(builder.bg_color.as_str(), "#ff5500");
}
#[test]
fn test_builder_wallpaper_ventura() {
let builder = HeadlessRecorderBuilder::new().wallpaper(Wallpaper::Ventura, 50);
assert!(builder.wallpaper.is_some());
let wp_config = builder.wallpaper.unwrap();
assert_eq!(wp_config.wallpaper, Wallpaper::Ventura);
assert_eq!(wp_config.padding, 50);
}
#[test]
fn test_builder_no_wallpaper() {
let builder = HeadlessRecorderBuilder::new()
.wallpaper(Wallpaper::Ventura, 50)
.no_wallpaper();
assert!(builder.wallpaper.is_none());
}
#[test]
fn test_builder_output_gif() {
let builder = HeadlessRecorderBuilder::new().output_gif("test.gif");
assert!(builder.generate_gif);
assert!(!builder.generate_mp4);
assert_eq!(builder.gif_path, Some(PathBuf::from("test.gif")));
}
#[test]
fn test_builder_output_mp4() {
let builder = HeadlessRecorderBuilder::new().output_mp4("test.mp4");
assert!(!builder.generate_gif);
assert!(builder.generate_mp4);
assert_eq!(builder.mp4_path, Some(PathBuf::from("test.mp4")));
}
#[test]
fn test_builder_output_both() {
let builder = HeadlessRecorderBuilder::new().output_both("demo");
assert!(builder.generate_gif);
assert!(builder.generate_mp4);
assert_eq!(builder.gif_path, Some(PathBuf::from("demo.gif")));
assert_eq!(builder.mp4_path, Some(PathBuf::from("demo.mp4")));
}
#[test]
fn test_recording_output_debug() {
let output = RecordingOutput {
gif_path: Some(PathBuf::from("test.gif")),
mp4_path: Some(PathBuf::from("test.mp4")),
frame_count: 100,
};
let debug_str = format!("{:?}", output);
assert!(debug_str.contains("test.gif"));
assert!(debug_str.contains("test.mp4"));
assert!(debug_str.contains("100"));
}
#[test]
fn test_with_defaults_for_demo() {
let builder = HeadlessRecorderBuilder::new().with_defaults_for_demo();
assert_eq!(builder.fps, 15);
assert_eq!(builder.decor, Decor::None);
assert!(builder.wallpaper.is_some());
let wp_config = builder.wallpaper.unwrap();
assert_eq!(wp_config.wallpaper, Wallpaper::Ventura);
assert_eq!(wp_config.padding, 60);
}
}