Skip to main content

browser_automation_cli/native/
screenshot.rs

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