forgewright 0.2.0

Standalone UI automation — CDP for browsers, UIA for Windows desktop apps
Documentation
//! Multi-frame burst capture with GIF encoding and cleanup.
//!
//! Captures N frames at configurable intervals using DXGI (preferred)
//! with GDI fallback. Supports animated GIF output with delta compression
//! and automatic cleanup of old captures.

use std::error::Error;
use std::path::{Path, PathBuf};

// ═══ TYPES ═══════════════════════════════════════════════════════════════════

/// Output format for burst captures.
#[derive(Debug, Clone, PartialEq)]
pub enum BurstFormat {
    /// Encode all frames into a single animated GIF.
    Gif,
    /// Save individual BMP files with index suffix.
    FrameSequence,
}

/// Configuration for burst capture.
#[derive(Debug, Clone)]
pub struct BurstConfig {
    /// Number of frames to capture (1-100, default 1).
    pub frame_count: u32,
    /// Interval between frames in milliseconds (default 100).
    pub interval_ms: u32,
    /// Output format: Gif or FrameSequence (individual BMPs).
    pub output_format: BurstFormat,
    /// Output directory.
    pub output_dir: PathBuf,
    /// Maximum total captures before auto-cleanup (default 50).
    pub max_captures: usize,
    /// Maximum total size in bytes before cleanup (default 100MB).
    pub max_bytes: u64,
}

impl Default for BurstConfig {
    fn default() -> Self {
        Self {
            frame_count: 1,
            interval_ms: 100,
            output_format: BurstFormat::FrameSequence,
            output_dir: PathBuf::from("screenshots"),
            max_captures: 50,
            max_bytes: 100 * 1024 * 1024,
        }
    }
}

/// Result of a burst capture operation.
pub struct BurstResult {
    /// Path to GIF or directory containing frame sequence.
    pub output_path: PathBuf,
    /// Individual frame paths (for FrameSequence) or single GIF path.
    pub frame_paths: Vec<PathBuf>,
    /// Timestamp of capture start.
    pub timestamp: String,
    /// Number of frames actually captured.
    pub frame_count: u32,
    /// Total bytes written.
    pub total_bytes: u64,
}

// ═══ VALIDATION ══════════════════════════════════════════════════════════════

/// Validate a BurstConfig. Returns Err if frame_count outside [1,100]
/// or interval_ms < 10.
fn validate_config(config: &BurstConfig) -> Result<(), Box<dyn Error>> {
    if config.frame_count < 1 || config.frame_count > 100 {
        return Err(format!(
            "frame_count must be 1-100, got {}",
            config.frame_count
        ).into());
    }
    if config.interval_ms < 10 {
        return Err(format!(
            "interval_ms must be >= 10, got {}",
            config.interval_ms
        ).into());
    }
    Ok(())
}

// ═══ CAPTURE BURST ═══════════════════════════════════════════════════════════

/// Type alias for a capture function used by the burst orchestrator.
/// Takes an output path, returns Ok(()) on success or an error.
pub type CaptureFn = dyn Fn(&Path) -> Result<(), Box<dyn Error>>;

/// Capture a burst of frames using the best available backend.
///
/// Uses DxgiCapturer (preferred) with GDI fallback.
/// Validates config, captures N frames at interval, handles partial failure.
pub fn capture_burst(config: &BurstConfig) -> Result<BurstResult, Box<dyn Error>> {
    // Use the platform-specific default capturer
    capture_burst_with_capturer(config, &default_capture_fn)
}

/// Capture a burst of frames using a provided capture function.
///
/// This variant allows injecting a mock capture backend for testing.
pub fn capture_burst_with_capturer(
    config: &BurstConfig,
    capture_fn: &CaptureFn,
) -> Result<BurstResult, Box<dyn Error>> {
    validate_config(config)?;

    std::fs::create_dir_all(&config.output_dir)?;

    let now = chrono_timestamp();
    let mut frame_paths: Vec<PathBuf> = Vec::new();
    let mut total_bytes: u64 = 0;

    for i in 0..config.frame_count {
        let filename = format!("burst_{}_{:03}.bmp", now, i + 1);
        let path = config.output_dir.join(&filename);

        match capture_fn(&path) {
            Ok(()) => {
                if let Ok(meta) = std::fs::metadata(&path) {
                    total_bytes += meta.len();
                }
                frame_paths.push(path);
            }
            Err(_) => {
                // Partial failure: stop capturing, return what we have
                break;
            }
        }

        // Sleep between frames (not after the last one)
        if i + 1 < config.frame_count {
            std::thread::sleep(std::time::Duration::from_millis(
                config.interval_ms as u64,
            ));
        }
    }

    let frame_count = frame_paths.len() as u32;

    // For now, output path is the output directory
    let output_path = config.output_dir.clone();

    Ok(BurstResult {
        output_path,
        frame_paths,
        timestamp: now,
        frame_count,
        total_bytes,
    })
}

/// Generate a timestamp string in YYYYMMDD_HHMMSS format.
fn chrono_timestamp() -> String {
    let now = std::time::SystemTime::now()
        .duration_since(std::time::UNIX_EPOCH)
        .unwrap_or_default();
    let secs = now.as_secs();
    let days = secs / 86400;
    let time_of_day = secs % 86400;
    let hours = time_of_day / 3600;
    let minutes = (time_of_day % 3600) / 60;
    let seconds = time_of_day % 60;

    let (year, month, day) = days_to_ymd(days);
    format!(
        "{:04}{:02}{:02}_{:02}{:02}{:02}",
        year, month, day, hours, minutes, seconds
    )
}

fn days_to_ymd(days_since_epoch: u64) -> (u32, u32, u32) {
    let z = days_since_epoch as i64 + 719468;
    let era = if z >= 0 { z } else { z - 146096 } / 146097;
    let doe = (z - era * 146097) as u64;
    let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365;
    let y = yoe as i64 + era * 400;
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
    let mp = (5 * doy + 2) / 153;
    let d = doy - (153 * mp + 2) / 5 + 1;
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
    let y = if m <= 2 { y + 1 } else { y };
    (y as u32, m as u32, d as u32)
}

/// Default capture function using DXGI with GDI fallback.
fn default_capture_fn(path: &Path) -> Result<(), Box<dyn Error>> {
    #[cfg(target_os = "windows")]
    {
        // Platform capture would go here
        let _ = path;
        Err("DXGI capture not yet wired in OSS crate".into())
    }
    #[cfg(not(target_os = "windows"))]
    {
        let _ = path;
        Err("capture not supported on this platform".into())
    }
}