car_browser/perception/
pipeline.rs1use async_trait::async_trait;
4use thiserror::Error;
5
6use super::ax_converter::AxConverter;
7use super::signals::SignalDetector;
8use super::ui_map::{TextBlock, UiMap};
9use crate::models::{A11yNode, Viewport};
10
11#[derive(Error, Debug)]
12pub enum PerceptionError {
13 #[error("Conversion failed: {0}")]
14 ConversionFailed(String),
15 #[error("Signal detection failed: {0}")]
16 SignalDetectionFailed(String),
17}
18
19#[async_trait]
21pub trait PerceptionPipeline: Send + Sync {
22 async fn perceive(
23 &self,
24 screenshot: &[u8],
25 a11y_nodes: &[A11yNode],
26 url: &str,
27 viewport: Viewport,
28 ) -> Result<UiMap, PerceptionError>;
29}
30
31pub struct BasicPerceptionPipeline {
33 converter: AxConverter,
34 signal_detector: SignalDetector,
35}
36
37impl BasicPerceptionPipeline {
38 pub fn new() -> Self {
39 Self {
40 converter: AxConverter::new(),
41 signal_detector: SignalDetector::new(),
42 }
43 }
44}
45
46impl Default for BasicPerceptionPipeline {
47 fn default() -> Self {
48 Self::new()
49 }
50}
51
52#[async_trait]
53impl PerceptionPipeline for BasicPerceptionPipeline {
54 async fn perceive(
55 &self,
56 _screenshot: &[u8],
57 a11y_nodes: &[A11yNode],
58 url: &str,
59 viewport: Viewport,
60 ) -> Result<UiMap, PerceptionError> {
61 let elements = self.converter.convert(a11y_nodes);
62 let text_blocks = self.extract_text_blocks(a11y_nodes);
63 let page_signals = self.signal_detector.detect(a11y_nodes);
64
65 Ok(UiMap::new(
66 url.to_string(),
67 elements,
68 text_blocks,
69 page_signals,
70 viewport,
71 String::new(),
72 ))
73 }
74}
75
76impl BasicPerceptionPipeline {
77 fn extract_text_blocks(&self, nodes: &[A11yNode]) -> Vec<TextBlock> {
78 extract_ax_text_blocks(nodes)
79 }
80}
81
82pub(crate) fn extract_ax_text_blocks(nodes: &[A11yNode]) -> Vec<TextBlock> {
86 nodes
87 .iter()
88 .filter(|n| {
89 let role = n.role.to_lowercase();
90 matches!(role.as_str(), "statictext" | "label" | "heading")
91 && n.name.is_some()
92 && n.bounds.width > 0.0
93 && n.bounds.height > 0.0
94 })
95 .map(|n| TextBlock::from_ax(n.name.clone().unwrap_or_default(), n.bounds))
96 .collect()
97}
98
99#[cfg(test)]
100mod tests {
101 use super::*;
102 use crate::models::Bounds;
103
104 #[tokio::test]
105 async fn test_basic_pipeline() {
106 let pipeline = BasicPerceptionPipeline::new();
107 let nodes = vec![
108 A11yNode {
109 node_id: "n0".to_string(),
110 role: "button".to_string(),
111 name: Some("Submit".to_string()),
112 value: None,
113 bounds: Bounds::new(100.0, 100.0, 80.0, 30.0),
114 children: vec![],
115 focusable: true,
116 focused: false,
117 disabled: false,
118 },
119 A11yNode {
120 node_id: "n1".to_string(),
121 role: "statictext".to_string(),
122 name: Some("Welcome".to_string()),
123 value: None,
124 bounds: Bounds::new(0.0, 0.0, 200.0, 20.0),
125 children: vec![],
126 focusable: false,
127 focused: false,
128 disabled: false,
129 },
130 ];
131 let viewport = Viewport {
132 width: 1280,
133 height: 720,
134 device_pixel_ratio: 2.0,
135 };
136
137 let ui_map = pipeline
138 .perceive(&[], &nodes, "https://example.com", viewport)
139 .await
140 .unwrap();
141 assert_eq!(ui_map.elements.len(), 2);
142 assert_eq!(ui_map.text_blocks.len(), 1);
143 assert_eq!(ui_map.text_blocks[0].text, "Welcome");
144 }
145}