Skip to main content

glass/browser/session/
visual.rs

1//! Screenshots, visual capture, and screencast.
2//!
3//! Captures viewport, clip-region, element-scoped, and full-page visuals
4//! with configurable format, quality, and scale. Also supports continuous
5//! screencast frame delivery.
6
7use super::*;
8
9impl BrowserSession {
10    /// Capture a PNG screenshot and return the raw PNG bytes.
11    ///
12    /// Policy-gated: requires the `Screenshot` capability. Base64-decodes
13    /// the CDP payload for direct file writing or image processing.
14    pub async fn screenshot_png(&self) -> BrowserResult<Vec<u8>> {
15        let data = self.screenshot_base64().await?;
16        Ok(STANDARD.decode(data.as_bytes())?)
17    }
18
19    /// Capture a PNG while preserving CDP's base64 payload for image APIs.
20    pub async fn screenshot_base64(&self) -> BrowserResult<String> {
21        self.policy.require(PolicyCapability::Screenshot)?;
22        self.cdp
23            .with_current_route(async { Ok(self.cdp.screenshot("png").await?) })
24            .await
25    }
26
27    /// Capture exact opt-in visual evidence with explicit effective metadata.
28    pub async fn capture_visual(
29        &self,
30        options: &VisualCaptureOptions,
31    ) -> BrowserResult<VisualCapture> {
32        self.policy.require(PolicyCapability::Screenshot)?;
33        validate_visual_options(options)?;
34        self.cdp
35            .with_current_route(async {
36                let metrics = self.cdp.get_layout_metrics().await?;
37                let dpr = runtime_value(&self.cdp.evaluate("devicePixelRatio").await?)?
38                    .as_f64()
39                    .unwrap_or(1.0);
40                let (_, selected_frame_id) = self.route_identity().await?;
41                let selected_child_frame = {
42                    let topology = self.topology.lock().await;
43                    topology
44                        .frames
45                        .iter()
46                        .find(|frame| frame.id == selected_frame_id)
47                        .is_some_and(|frame| frame.parent_id.is_some())
48                };
49                if selected_child_frame {
50                    return Err("exact visual capture of a selected child frame is not supported; select its page target or the main frame".into());
51                }
52                let mut clip = options.clip;
53                if options.full_page {
54                    clip = Some(visual_rect(&metrics["cssContentSize"])?);
55                } else if let Some(target) = options.target.as_deref() {
56                    let element = self.resolve_element(target).await?;
57                    let model = match (element.node_id, element.backend_dom_node_id) {
58                        (Some(node_id), _) => self.cdp.get_box_model(node_id).await?,
59                        (_, Some(backend_id)) => {
60                            self.cdp.get_box_model_for_backend(backend_id).await?
61                        }
62                        _ => return Err("visual target has no DOM node identity".into()),
63                    };
64                    let mut element_clip = visual_quad_rect(&model["model"]["border"])?;
65                    let viewport = visual_viewport_rect(&metrics["cssVisualViewport"])?;
66                    element_clip.x += viewport.x;
67                    element_clip.y += viewport.y;
68                    clip = Some(element_clip);
69                } else if clip.is_none() && options.scale != 1.0 {
70                    clip = Some(visual_viewport_rect(&metrics["cssVisualViewport"])?);
71                }
72                let viewport = visual_viewport_rect(&metrics["cssVisualViewport"])?;
73                validate_effective_visual_clip(
74                    Some(clip.unwrap_or(viewport)),
75                    if clip.is_some() { options.scale } else { dpr },
76                )?;
77                let mut params = serde_json::json!({
78                    "format": options.format.as_cdp(),
79                    "optimizeForSpeed": true,
80                    "captureBeyondViewport": options.full_page || clip.is_some(),
81                    "fromSurface": true
82                });
83                if let Some(quality) = options.quality {
84                    params["quality"] = Value::from(quality);
85                }
86                if let Some(clip) = clip {
87                    params["clip"] = serde_json::json!({
88                        "x": clip.x,
89                        "y": clip.y,
90                        "width": clip.width,
91                        "height": clip.height,
92                        "scale": options.scale
93                    });
94                }
95                if options.full_page {
96                    let latest = self.cdp.get_layout_metrics().await?;
97                    let latest_clip = visual_rect(&latest["cssContentSize"])?;
98                    if !visual_clips_match(clip.expect("full-page capture has a clip"), latest_clip) {
99                        return Err("full-page geometry changed during capture preparation".into());
100                    }
101                } else if let Some(target) = options.target.as_deref() {
102                    let element = self.resolve_element(target).await?;
103                    let model = match (element.node_id, element.backend_dom_node_id) {
104                        (Some(node_id), _) => self.cdp.get_box_model(node_id).await?,
105                        (_, Some(backend_id)) => self.cdp.get_box_model_for_backend(backend_id).await?,
106                        _ => return Err("visual target has no DOM node identity".into()),
107                    };
108                    let latest_metrics = self.cdp.get_layout_metrics().await?;
109                    let viewport = visual_viewport_rect(&latest_metrics["cssVisualViewport"])?;
110                    let mut latest_clip = visual_quad_rect(&model["model"]["border"])?;
111                    latest_clip.x += viewport.x;
112                    latest_clip.y += viewport.y;
113                    if !visual_clips_match(clip.expect("element capture has a clip"), latest_clip) {
114                        return Err("element geometry changed during capture preparation".into());
115                    }
116                }
117                let data = self.cdp.screenshot_with_params(params).await?;
118                if data.len() > MAX_VISUAL_BASE64_BYTES {
119                    return Err("visual base64 payload exceeded 64 MiB".into());
120                }
121                let encoded_bytes = decoded_base64_len(&data)?;
122                let header_end = data.len().min(VISUAL_HEADER_BASE64_BYTES) / 4 * 4;
123                let header = STANDARD.decode(&data.as_bytes()[..header_end])?;
124                let size = imagesize::blob_size(&header)?;
125                let (target_id, frame_id) = self.route_identity().await?;
126                Ok(VisualCapture {
127                    metadata: VisualCaptureMetadata {
128                        format: options.format,
129                        width: size.width,
130                        height: size.height,
131                        encoded_bytes,
132                        device_scale_factor: dpr,
133                        scale: options.scale,
134                        full_page: options.full_page,
135                        clip,
136                        target_id,
137                        frame_id,
138                    },
139                    data,
140                })
141            })
142            .await
143    }
144
145    pub async fn start_screencast(
146        &self,
147        format: VisualFormat,
148        quality: u8,
149        max_width: u32,
150        max_height: u32,
151    ) -> BrowserResult<ScreencastScope> {
152        self.policy.require(PolicyCapability::Screenshot)?;
153        if format == VisualFormat::Webp {
154            return Err("CDP screencast supports only png or jpeg".into());
155        }
156        if quality > 100
157            || max_width == 0
158            || max_height == 0
159            || max_width > 4096
160            || max_height > 4096
161            || f64::from(max_width) * f64::from(max_height) > MAX_VISUAL_PIXELS
162        {
163            return Err(
164                "screencast quality must be 0..=100 and dimensions must fit the 8 MP budget".into(),
165            );
166        }
167        let session_id = self.cdp.current_session_id();
168        let receiver = self.cdp.open_screencast_channel(session_id.clone())?;
169        let mut startup = ScreencastStartupGuard {
170            cdp: self.cdp.clone(),
171            session_id: session_id.clone(),
172            armed: true,
173        };
174        let parameters = Some(serde_json::json!({
175            "format": format.as_cdp(),
176            "quality": quality,
177            "maxWidth": max_width,
178            "maxHeight": max_height,
179            "everyNthFrame": 1
180        }));
181        let start_result = match session_id.as_deref() {
182            Some(session_id) => {
183                self.cdp
184                    .send_to_session(session_id, "Page.startScreencast", parameters)
185                    .await
186            }
187            None => self.cdp.send("Page.startScreencast", parameters).await,
188        };
189        if let Err(error) = start_result {
190            return Err(error.into());
191        }
192        startup.disarm();
193        Ok(ScreencastScope {
194            cdp: self.cdp.clone(),
195            session_id,
196            receiver,
197            armed: true,
198        })
199    }
200}