Skip to main content

browser_automation_cli/native/
screenshot.rs

1#![allow(missing_docs)]
2use serde::Serialize;
3use serde_json::Value;
4use std::path::PathBuf;
5
6use std::collections::HashMap;
7
8use super::cdp::client::CdpClient;
9use super::cdp::types::*;
10use super::element::RefMap;
11
12const ANNOTATION_OVERLAY_ID: &str = "__browser_automation_cli_annotations__";
13
14#[derive(Debug, Clone)]
15struct Rect {
16    x: f64,
17    y: f64,
18    width: f64,
19    height: f64,
20}
21
22#[derive(Debug, Clone)]
23struct RawAnnotation {
24    ref_id: String,
25    number: u64,
26    role: String,
27    name: Option<String>,
28    rect: Rect,
29}
30
31#[derive(Debug, Clone, Serialize)]
32pub struct AnnotationBox {
33    pub x: i64,
34    pub y: i64,
35    pub width: i64,
36    pub height: i64,
37}
38
39#[derive(Debug, Clone)]
40pub struct ScreenshotAnnotation {
41    pub ref_id: String,
42    pub number: u64,
43    pub role: String,
44    pub name: Option<String>,
45    pub box_: AnnotationBox,
46}
47
48#[derive(Debug, Clone)]
49pub struct ScreenshotResult {
50    pub path: String,
51    pub base64: String,
52    pub annotations: Vec<ScreenshotAnnotation>,
53}
54
55#[derive(Debug, Clone)]
56pub struct ScreenshotOptions {
57    pub selector: Option<String>,
58    pub path: Option<String>,
59    pub full_page: bool,
60    pub format: String,
61    pub quality: Option<i32>,
62    pub annotate: bool,
63    pub output_dir: Option<String>,
64}
65
66impl Default for ScreenshotOptions {
67    fn default() -> Self {
68        Self {
69            selector: None,
70            path: None,
71            full_page: false,
72            format: "png".to_string(),
73            quality: None,
74            annotate: false,
75            output_dir: None,
76        }
77    }
78}
79
80impl Serialize for ScreenshotAnnotation {
81    fn serialize<S>(&self, serializer: S) -> Result<S::Ok, S::Error>
82    where
83        S: serde::Serializer,
84    {
85        use serde::ser::SerializeStruct;
86
87        let mut state = serializer.serialize_struct("ScreenshotAnnotation", 5)?;
88        state.serialize_field("ref", &self.ref_id)?;
89        state.serialize_field("number", &self.number)?;
90        state.serialize_field("role", &self.role)?;
91        if let Some(name) = &self.name {
92            state.serialize_field("name", name)?;
93        }
94        state.serialize_field("box", &self.box_)?;
95        state.end()
96    }
97}
98
99/// Captures a screenshot via CDP and optionally overlays numbered annotations
100/// that mirror the Node.js screenshot `annotate` mode.
101pub async fn take_screenshot(
102    client: &CdpClient,
103    session_id: &str,
104    ref_map: &RefMap,
105    options: &ScreenshotOptions,
106    iframe_sessions: &HashMap<String, String>,
107) -> Result<ScreenshotResult, String> {
108    let target_rect = if options.annotate {
109        match options.selector.as_deref() {
110            Some(selector) => {
111                get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions)
112                    .await?
113            }
114            None => None,
115        }
116    } else {
117        None
118    };
119
120    let raw_annotations = if options.annotate {
121        collect_annotations(client, session_id, ref_map).await?
122    } else {
123        Vec::new()
124    };
125
126    let overlay_items = filter_annotations(raw_annotations, target_rect.as_ref());
127    let overlay_injected = if options.annotate && !overlay_items.is_empty() {
128        inject_annotation_overlay(client, session_id, &overlay_items).await?;
129        true
130    } else {
131        false
132    };
133
134    let base64 =
135        capture_screenshot_base64(client, session_id, ref_map, options, iframe_sessions).await;
136
137    if overlay_injected {
138        let _ = remove_annotation_overlay(client, session_id).await;
139    }
140
141    let base64 = base64?;
142    let annotations = if options.annotate {
143        let scroll = if options.full_page {
144            Some(get_scroll_offsets(client, session_id).await?)
145        } else {
146            None
147        };
148        project_annotations(&overlay_items, target_rect.as_ref(), scroll)
149    } else {
150        Vec::new()
151    };
152
153    let ext = if options.format == "jpeg" {
154        "jpg"
155    } else {
156        "png"
157    };
158    let path = save_screenshot(
159        &base64,
160        options.path.as_deref(),
161        ext,
162        options.output_dir.as_deref(),
163    )?;
164
165    Ok(ScreenshotResult {
166        path,
167        base64,
168        annotations,
169    })
170}
171
172async fn capture_screenshot_base64(
173    client: &CdpClient,
174    session_id: &str,
175    ref_map: &RefMap,
176    options: &ScreenshotOptions,
177    iframe_sessions: &HashMap<String, String>,
178) -> Result<String, String> {
179    let mut params = CaptureScreenshotParams {
180        format: Some(options.format.clone()),
181        quality: if options.format == "jpeg" {
182            options.quality.or(Some(80))
183        } else {
184            None
185        },
186        clip: None,
187        from_surface: Some(true),
188        capture_beyond_viewport: if options.full_page { Some(true) } else { None },
189    };
190
191    if options.full_page {
192        let metrics: Value = client
193            .send_command_no_params("Page.getLayoutMetrics", Some(session_id))
194            .await?;
195
196        let content_size = metrics
197            .get("contentSize")
198            .or_else(|| metrics.get("cssContentSize"));
199        if let Some(size) = content_size {
200            let width = size.get("width").and_then(|v| v.as_f64()).unwrap_or(1280.0);
201            let height = size.get("height").and_then(|v| v.as_f64()).unwrap_or(720.0);
202
203            params.clip = Some(Viewport {
204                x: 0.0,
205                y: 0.0,
206                width,
207                height,
208                scale: 1.0,
209            });
210        }
211    } else if let Some(ref selector) = options.selector {
212        if let Some(rect) =
213            get_rect_for_selector(client, session_id, ref_map, selector, iframe_sessions).await?
214        {
215            params.clip = Some(Viewport {
216                x: rect.x,
217                y: rect.y,
218                width: rect.width,
219                height: rect.height,
220                scale: 1.0,
221            });
222        }
223    }
224
225    let result: CaptureScreenshotResult = client
226        .send_command_typed("Page.captureScreenshot", &params, Some(session_id))
227        .await?;
228
229    Ok(result.data)
230}
231
232async fn collect_annotations(
233    client: &CdpClient,
234    session_id: &str,
235    ref_map: &RefMap,
236) -> Result<Vec<RawAnnotation>, String> {
237    let entries = ref_map.entries_sorted();
238    if entries.is_empty() {
239        return Ok(Vec::new());
240    }
241
242    // Collect entries that have backend_node_ids for batch resolution.
243    let with_backend_ids: Vec<(String, super::element::RefEntry, i64)> = entries
244        .iter()
245        .filter_map(|(ref_id, entry)| {
246            entry
247                .backend_node_id
248                .map(|bid| (ref_id.clone(), entry.clone(), bid))
249        })
250        .collect();
251
252    if with_backend_ids.is_empty() {
253        return Ok(Vec::new());
254    }
255
256    // Batch-resolve all backend_node_ids to object IDs using concurrent CDP calls.
257    let resolve_futures: Vec<_> = with_backend_ids
258        .iter()
259        .map(|(_, _, backend_node_id)| {
260            client.send_command(
261                "DOM.resolveNode",
262                Some(serde_json::json!({
263                    "backendNodeId": backend_node_id,
264                    "objectGroup": "browser-automation-cli-annotate"
265                })),
266                Some(session_id),
267            )
268        })
269        .collect();
270
271    let resolve_results = futures_util::future::join_all(resolve_futures).await;
272
273    // Collect resolved object IDs paired with their ref info.
274    let mut resolved: Vec<(String, super::element::RefEntry, String)> = Vec::new();
275    for (i, result) in resolve_results.into_iter().enumerate() {
276        if let Ok(val) = result {
277            if let Some(oid) = val
278                .get("object")
279                .and_then(|o| o.get("objectId"))
280                .and_then(|v| v.as_str())
281            {
282                let (ref_id, entry, _) = &with_backend_ids[i];
283                resolved.push((ref_id.clone(), entry.clone(), oid.to_string()));
284            }
285        }
286    }
287
288    if resolved.is_empty() {
289        return Ok(Vec::new());
290    }
291
292    // Batch-get bounding rects for all resolved elements using concurrent CDP calls.
293    let rect_futures: Vec<_> = resolved
294        .iter()
295        .map(|(_, _, object_id)| get_rect_for_object(client, session_id, object_id))
296        .collect();
297
298    let rect_results = futures_util::future::join_all(rect_futures).await;
299
300    let mut annotations = Vec::new();
301    for (i, rect_result) in rect_results.into_iter().enumerate() {
302        let rect = match rect_result {
303            Ok(Some(r)) if r.width > 0.0 && r.height > 0.0 => r,
304            _ => continue,
305        };
306
307        let (ref_id, entry, _) = &resolved[i];
308        let number = ref_id
309            .strip_prefix('e')
310            .and_then(|n| n.parse::<u64>().ok())
311            .unwrap_or(0);
312
313        annotations.push(RawAnnotation {
314            ref_id: ref_id.clone(),
315            number,
316            role: entry.role.clone(),
317            name: (!entry.name.is_empty()).then_some(entry.name.clone()),
318            rect,
319        });
320    }
321
322    Ok(annotations)
323}
324
325async fn get_rect_for_selector(
326    client: &CdpClient,
327    session_id: &str,
328    ref_map: &RefMap,
329    selector: &str,
330    iframe_sessions: &HashMap<String, String>,
331) -> Result<Option<Rect>, String> {
332    let (object_id, effective_session_id) = super::element::resolve_element_object_id(
333        client,
334        session_id,
335        ref_map,
336        selector,
337        iframe_sessions,
338    )
339    .await?;
340    get_rect_for_object(client, &effective_session_id, &object_id).await
341}
342
343async fn get_rect_for_object(
344    client: &CdpClient,
345    session_id: &str,
346    object_id: &str,
347) -> Result<Option<Rect>, String> {
348    let result: EvaluateResult = client
349        .send_command_typed(
350            "Runtime.callFunctionOn",
351            &CallFunctionOnParams {
352                function_declaration: r#"function() {
353                    const rect = this.getBoundingClientRect();
354                    return { x: rect.x, y: rect.y, width: rect.width, height: rect.height };
355                }"#
356                .to_string(),
357                object_id: Some(object_id.to_string()),
358                arguments: None,
359                return_by_value: Some(true),
360                await_promise: Some(false),
361            },
362            Some(session_id),
363        )
364        .await?;
365
366    Ok(result.result.value.as_ref().and_then(parse_rect))
367}
368
369fn parse_rect(value: &Value) -> Option<Rect> {
370    Some(Rect {
371        x: value.get("x")?.as_f64()?,
372        y: value.get("y")?.as_f64()?,
373        width: value.get("width")?.as_f64()?,
374        height: value.get("height")?.as_f64()?,
375    })
376}
377
378fn filter_annotations(
379    annotations: Vec<RawAnnotation>,
380    target_rect: Option<&Rect>,
381) -> Vec<RawAnnotation> {
382    let mut items = annotations
383        .into_iter()
384        .filter(|annotation| match target_rect {
385            Some(target) => overlaps(&annotation.rect, target),
386            None => true,
387        })
388        .collect::<Vec<_>>();
389
390    items.sort_by_key(|annotation| annotation.number);
391    items
392}
393
394fn overlaps(left: &Rect, right: &Rect) -> bool {
395    let left_x2 = left.x + left.width;
396    let left_y2 = left.y + left.height;
397    let right_x2 = right.x + right.width;
398    let right_y2 = right.y + right.height;
399
400    left.x < right_x2 && left_x2 > right.x && left.y < right_y2 && left_y2 > right.y
401}
402
403async fn inject_annotation_overlay(
404    client: &CdpClient,
405    session_id: &str,
406    annotations: &[RawAnnotation],
407) -> Result<(), String> {
408    let overlay_data = annotations
409        .iter()
410        .map(|annotation| {
411            serde_json::json!({
412                "number": annotation.number,
413                "x": round(annotation.rect.x),
414                "y": round(annotation.rect.y),
415                "width": round(annotation.rect.width),
416                "height": round(annotation.rect.height),
417            })
418        })
419        .collect::<Vec<_>>();
420
421    let expression = format!(
422        r#"(() => {{
423            var items = {items};
424            var id = {overlay_id};
425            var existing = document.getElementById(id);
426            if (existing) existing.remove();
427            var sx = window.scrollX || 0;
428            var sy = window.scrollY || 0;
429            var c = document.createElement('div');
430            c.id = id;
431            c.style.cssText = 'position:absolute;top:0;left:0;width:0;height:0;pointer-events:none;z-index:2147483647;';
432            for (var i = 0; i < items.length; i++) {{
433                var it = items[i];
434                var dx = it.x + sx;
435                var dy = it.y + sy;
436                var b = document.createElement('div');
437                b.style.cssText = 'position:absolute;left:' + dx + 'px;top:' + dy + 'px;width:' + it.width + 'px;height:' + it.height + 'px;border:2px solid rgba(255,0,0,0.8);box-sizing:border-box;pointer-events:none;';
438                var l = document.createElement('div');
439                l.textContent = String(it.number);
440                var labelTop = dy < 14 ? '2px' : '-14px';
441                l.style.cssText = 'position:absolute;top:' + labelTop + ';left:-2px;background:rgba(255,0,0,0.9);color:#fff;font:bold 11px/14px monospace;padding:0 4px;border-radius:2px;white-space:nowrap;';
442                b.appendChild(l);
443                c.appendChild(b);
444            }}
445            document.documentElement.appendChild(c);
446            return true;
447        }})()"#,
448        items = serde_json::to_string(&overlay_data).unwrap_or_else(|_| "[]".to_string()),
449        overlay_id =
450            serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
451    );
452
453    let _: EvaluateResult = client
454        .send_command_typed(
455            "Runtime.evaluate",
456            &EvaluateParams {
457                expression,
458                return_by_value: Some(true),
459                await_promise: Some(false),
460            },
461            Some(session_id),
462        )
463        .await?;
464
465    Ok(())
466}
467
468async fn remove_annotation_overlay(client: &CdpClient, session_id: &str) -> Result<(), String> {
469    let expression = format!(
470        r#"(() => {{
471            var el = document.getElementById({overlay_id});
472            if (el) el.remove();
473            return true;
474        }})()"#,
475        overlay_id =
476            serde_json::to_string(ANNOTATION_OVERLAY_ID).unwrap_or_else(|_| "\"\"".to_string()),
477    );
478
479    let _: EvaluateResult = client
480        .send_command_typed(
481            "Runtime.evaluate",
482            &EvaluateParams {
483                expression,
484                return_by_value: Some(true),
485                await_promise: Some(false),
486            },
487            Some(session_id),
488        )
489        .await?;
490
491    Ok(())
492}
493
494async fn get_scroll_offsets(client: &CdpClient, session_id: &str) -> Result<(f64, f64), String> {
495    let result: EvaluateResult = client
496        .send_command_typed(
497            "Runtime.evaluate",
498            &EvaluateParams {
499                expression: "({x: window.scrollX || 0, y: window.scrollY || 0})".to_string(),
500                return_by_value: Some(true),
501                await_promise: Some(false),
502            },
503            Some(session_id),
504        )
505        .await?;
506
507    let value = result.result.value.unwrap_or(Value::Null);
508    let x = value.get("x").and_then(|v| v.as_f64()).unwrap_or(0.0);
509    let y = value.get("y").and_then(|v| v.as_f64()).unwrap_or(0.0);
510    Ok((x, y))
511}
512
513fn project_annotations(
514    annotations: &[RawAnnotation],
515    target_rect: Option<&Rect>,
516    scroll: Option<(f64, f64)>,
517) -> Vec<ScreenshotAnnotation> {
518    annotations
519        .iter()
520        .map(|annotation| {
521            let rect = if let Some(target) = target_rect {
522                Rect {
523                    x: annotation.rect.x - target.x,
524                    y: annotation.rect.y - target.y,
525                    width: annotation.rect.width,
526                    height: annotation.rect.height,
527                }
528            } else if let Some((scroll_x, scroll_y)) = scroll {
529                Rect {
530                    x: annotation.rect.x + scroll_x,
531                    y: annotation.rect.y + scroll_y,
532                    width: annotation.rect.width,
533                    height: annotation.rect.height,
534                }
535            } else {
536                annotation.rect.clone()
537            };
538
539            ScreenshotAnnotation {
540                ref_id: annotation.ref_id.clone(),
541                number: annotation.number,
542                role: annotation.role.clone(),
543                name: annotation.name.clone(),
544                box_: AnnotationBox {
545                    x: round(rect.x),
546                    y: round(rect.y),
547                    width: round(rect.width),
548                    height: round(rect.height),
549                },
550            }
551        })
552        .collect()
553}
554
555fn save_screenshot(
556    base64_data: &str,
557    explicit_path: Option<&str>,
558    ext: &str,
559    output_dir: Option<&str>,
560) -> Result<String, String> {
561    let save_path = match explicit_path {
562        Some(path) => path.to_string(),
563        None => {
564            let dir = match output_dir {
565                Some(d) => PathBuf::from(d),
566                None => get_screenshot_dir(),
567            };
568            let _ = std::fs::create_dir_all(&dir);
569            let timestamp = std::time::SystemTime::now()
570                .duration_since(std::time::UNIX_EPOCH)
571                .unwrap_or_default()
572                .as_millis();
573            let name = format!("screenshot-{}.{}", timestamp, ext);
574            dir.join(name).to_string_lossy().to_string()
575        }
576    };
577
578    let bytes = base64::Engine::decode(&base64::engine::general_purpose::STANDARD, base64_data)
579        .map_err(|e| format!("Failed to decode screenshot: {}", e))?;
580
581    std::fs::write(&save_path, &bytes)
582        .map_err(|e| format!("Failed to save screenshot to {}: {}", save_path, e))?;
583
584    Ok(save_path)
585}
586
587fn round(value: f64) -> i64 {
588    value.round() as i64
589}
590
591fn get_screenshot_dir() -> PathBuf {
592    if let Some(home) = dirs::home_dir() {
593        home.join(".browser-automation-cli")
594            .join("tmp")
595            .join("screenshots")
596    } else {
597        std::env::temp_dir()
598            .join("browser-automation-cli")
599            .join("screenshots")
600    }
601}
602
603#[cfg(test)]
604mod tests {
605    use super::*;
606
607    #[test]
608    fn filters_annotations_to_target_overlap() {
609        let annotations = vec![
610            RawAnnotation {
611                ref_id: "e1".to_string(),
612                number: 1,
613                role: "button".to_string(),
614                name: Some("Inside".to_string()),
615                rect: Rect {
616                    x: 10.0,
617                    y: 10.0,
618                    width: 50.0,
619                    height: 20.0,
620                },
621            },
622            RawAnnotation {
623                ref_id: "e2".to_string(),
624                number: 2,
625                role: "button".to_string(),
626                name: Some("Outside".to_string()),
627                rect: Rect {
628                    x: 200.0,
629                    y: 200.0,
630                    width: 40.0,
631                    height: 20.0,
632                },
633            },
634        ];
635
636        let target = Rect {
637            x: 0.0,
638            y: 0.0,
639            width: 100.0,
640            height: 100.0,
641        };
642
643        let filtered = filter_annotations(annotations, Some(&target));
644        assert_eq!(filtered.len(), 1);
645        assert_eq!(filtered[0].ref_id, "e1");
646    }
647
648    #[test]
649    fn projects_selector_annotations_relative_to_target() {
650        let annotations = vec![RawAnnotation {
651            ref_id: "e1".to_string(),
652            number: 1,
653            role: "button".to_string(),
654            name: Some("Inside".to_string()),
655            rect: Rect {
656                x: 25.0,
657                y: 35.0,
658                width: 40.0,
659                height: 20.0,
660            },
661        }];
662
663        let target = Rect {
664            x: 10.0,
665            y: 15.0,
666            width: 100.0,
667            height: 100.0,
668        };
669
670        let projected = project_annotations(&annotations, Some(&target), None);
671        assert_eq!(projected[0].box_.x, 15);
672        assert_eq!(projected[0].box_.y, 20);
673    }
674
675    #[test]
676    fn screenshot_options_accept_full_page_quality_element() {
677        let opts = ScreenshotOptions {
678            full_page: true,
679            quality: Some(72),
680            format: "jpeg".into(),
681            ..ScreenshotOptions::default()
682        };
683        assert!(opts.full_page);
684        assert_eq!(opts.quality, Some(72));
685        assert_eq!(opts.format, "jpeg");
686    }
687
688    #[test]
689    fn projects_full_page_annotations_to_document_space() {
690        let annotations = vec![RawAnnotation {
691            ref_id: "e1".to_string(),
692            number: 1,
693            role: "button".to_string(),
694            name: Some("Bottom".to_string()),
695            rect: Rect {
696                x: 5.0,
697                y: 12.0,
698                width: 40.0,
699                height: 20.0,
700            },
701        }];
702
703        let projected = project_annotations(&annotations, None, Some((10.0, 1000.0)));
704        assert_eq!(projected[0].box_.x, 15);
705        assert_eq!(projected[0].box_.y, 1012);
706    }
707}