Skip to main content

web_capture/
animation.rs

1//! Animation capture module (R2).
2//!
3//! Captures web animations as sequences of screenshots with
4//! loop detection based on pixel similarity comparison.
5//!
6//! Supports three capture modes (concept):
7//! - `screencast`: CDP-based push capture (30-60 FPS, Chromium only)
8//! - `beginframe`: Deterministic frame-perfect capture (Chromium only)
9//! - `screenshot`: Polling-based capture (3-8 FPS, cross-browser)
10//!
11//! Note: Full animation capture requires browser automation (browser-commander).
12//! This module provides the core logic; browser integration is stubbed
13//! until browser-commander is fully available.
14//!
15//! Based on reference implementation from:
16//! <https://github.com/link-foundation/meta-theory/blob/main/scripts/capture-animation.mjs>
17
18use serde::{Deserialize, Serialize};
19
20/// Capture mode for animation.
21#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
22#[serde(rename_all = "lowercase")]
23pub enum CaptureMode {
24    /// Polling-based capture (cross-browser compatible)
25    #[default]
26    Screenshot,
27    /// CDP-based push capture (Chromium only)
28    Screencast,
29    /// Deterministic frame-perfect capture (Chromium only)
30    Beginframe,
31}
32
33impl std::fmt::Display for CaptureMode {
34    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
35        match self {
36            Self::Screenshot => write!(f, "screenshot"),
37            Self::Screencast => write!(f, "screencast"),
38            Self::Beginframe => write!(f, "beginframe"),
39        }
40    }
41}
42
43impl std::str::FromStr for CaptureMode {
44    type Err = String;
45
46    fn from_str(s: &str) -> Result<Self, Self::Err> {
47        match s.to_lowercase().as_str() {
48            "screenshot" => Ok(Self::Screenshot),
49            "screencast" => Ok(Self::Screencast),
50            "beginframe" => Ok(Self::Beginframe),
51            _ => Err(format!("Unknown capture mode: {s}")),
52        }
53    }
54}
55
56/// Output format for animation.
57#[derive(Debug, Clone, Copy, Default, PartialEq, Eq, Serialize, Deserialize)]
58#[serde(rename_all = "lowercase")]
59pub enum AnimationFormat {
60    #[default]
61    Gif,
62    PngSequence,
63}
64
65/// Options for animation capture.
66#[allow(clippy::struct_excessive_bools)]
67#[derive(Debug, Clone, Serialize, Deserialize)]
68pub struct AnimationOptions {
69    pub max_size: u32,
70    pub viewport_width: u32,
71    pub viewport_height: u32,
72    pub interval: u32,
73    pub fps: Option<u32>,
74    pub speed: f64,
75    pub min_frames: u32,
76    pub loop_timeout: u32,
77    pub static_timeout: u32,
78    pub similarity: f64,
79    pub crop: bool,
80    pub capture_mode: CaptureMode,
81    pub format: AnimationFormat,
82    pub extract_keyframes: bool,
83    pub dismiss_popups: bool,
84}
85
86impl Default for AnimationOptions {
87    fn default() -> Self {
88        Self {
89            max_size: 1024,
90            viewport_width: 1920,
91            viewport_height: 1080,
92            interval: 0,
93            fps: None,
94            speed: 1.0,
95            min_frames: 120,
96            loop_timeout: 60,
97            static_timeout: 60,
98            similarity: 0.99,
99            crop: true,
100            capture_mode: CaptureMode::default(),
101            format: AnimationFormat::default(),
102            extract_keyframes: false,
103            dismiss_popups: true,
104        }
105    }
106}
107
108/// A captured keyframe.
109#[derive(Debug, Clone, Serialize)]
110pub struct Keyframe {
111    pub index: usize,
112    #[serde(skip)]
113    pub buffer: Vec<u8>,
114}
115
116/// Result of animation capture.
117#[derive(Debug, Clone, Serialize)]
118pub struct AnimationCaptureResult {
119    #[serde(skip)]
120    pub frames: Vec<Vec<u8>>,
121    pub timestamps: Vec<u64>,
122    pub loop_detected: bool,
123    pub loop_frame: i64,
124    pub total_frames: usize,
125    pub duration: u64,
126    pub keyframes: Option<Vec<Keyframe>>,
127    pub width: u32,
128    pub height: u32,
129}
130
131/// Compare two frame buffers for pixel similarity.
132///
133/// Returns a similarity score between 0.0 and 1.0.
134#[must_use]
135pub fn compare_frames(frame1: &[u8], frame2: &[u8]) -> f64 {
136    if frame1.is_empty() || frame2.is_empty() {
137        return 0.0;
138    }
139    if frame1.len() != frame2.len() {
140        return 0.0;
141    }
142
143    let matching_bytes = frame1
144        .iter()
145        .zip(frame2.iter())
146        .filter(|(a, b)| a == b)
147        .count();
148
149    #[allow(clippy::cast_precision_loss)]
150    {
151        matching_bytes as f64 / frame1.len() as f64
152    }
153}
154
155/// Capture animation frames from a web page.
156///
157/// Note: Full browser automation requires browser-commander.
158/// This implementation returns an error indicating the requirement.
159#[allow(clippy::unused_async)]
160pub async fn capture_animation_frames(
161    url: &str,
162    _options: &AnimationOptions,
163) -> crate::Result<AnimationCaptureResult> {
164    let _absolute_url = if url.starts_with("http") {
165        url.to_string()
166    } else {
167        format!("https://{url}")
168    };
169
170    // Animation capture requires full browser automation
171    Err(crate::WebCaptureError::BrowserError(
172        "Animation capture requires Chrome/Chromium. Install it and enable browser-commander features.".to_string()
173    ))
174}