diff --git a/crates/ely_servo_host/src/error.rs b/crates/ely_servo_host/src/error.rs index 02d0d65..85570b0 100644 --- a/crates/ely_servo_host/src/error.rs +++ b/crates/ely_servo_host/src/error.rs @@ -20,4 +20,7 @@ pub enum ServoHostError { #[error("servo rendering context could not be made current")] RenderingContextNotCurrent, + + #[error("servo rendered frame is unavailable")] + RenderedFrameUnavailable, } diff --git a/crates/ely_servo_host/src/host.rs b/crates/ely_servo_host/src/host.rs index 51ff044..b7e4d76 100644 --- a/crates/ely_servo_host/src/host.rs +++ b/crates/ely_servo_host/src/host.rs @@ -11,6 +11,118 @@ pub enum WebViewState { Crashed, } +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RenderedFrameSummary { + width: u32, + height: u32, + opaque_pixel_count: u64, + non_white_pixel_count: u64, + sample_hash: u64, +} + +impl RenderedFrameSummary { + #[must_use] + pub fn from_rgba_bytes(width: u32, height: u32, rgba_bytes: &[u8]) -> Self { + let mut opaque_pixel_count = 0; + let mut non_white_pixel_count = 0; + let mut sample_hash = 0xcbf29ce484222325_u64; + + for (index, pixel) in rgba_bytes.chunks_exact(4).enumerate() { + let [red, green, blue, alpha] = [pixel[0], pixel[1], pixel[2], pixel[3]]; + if alpha > 0 { + opaque_pixel_count += 1; + } + if alpha > 0 && (red < 245 || green < 245 || blue < 245) { + non_white_pixel_count += 1; + } + if index % 97 == 0 { + for byte in pixel { + sample_hash ^= u64::from(*byte); + sample_hash = sample_hash.wrapping_mul(0x100000001b3); + } + } + } + + Self { width, height, opaque_pixel_count, non_white_pixel_count, sample_hash } + } + + #[must_use] + pub fn width(&self) -> u32 { + self.width + } + + #[must_use] + pub fn height(&self) -> u32 { + self.height + } + + #[must_use] + pub fn opaque_pixel_count(&self) -> u64 { + self.opaque_pixel_count + } + + #[must_use] + pub fn non_white_pixel_count(&self) -> u64 { + self.non_white_pixel_count + } + + #[must_use] + pub fn sample_hash(&self) -> u64 { + self.sample_hash + } +} + +#[derive(Clone, Debug, Eq, PartialEq)] +pub struct RenderedFrame { + width: u32, + height: u32, + rgba_bytes: Vec, + summary: RenderedFrameSummary, +} + +impl RenderedFrame { + #[must_use] + pub fn from_rgba_bytes(width: u32, height: u32, rgba_bytes: Vec) -> Self { + let summary = RenderedFrameSummary::from_rgba_bytes(width, height, &rgba_bytes); + Self { width, height, rgba_bytes, summary } + } + + #[must_use] + pub fn width(&self) -> u32 { + self.width + } + + #[must_use] + pub fn height(&self) -> u32 { + self.height + } + + #[must_use] + pub fn rgba_bytes(&self) -> &[u8] { + &self.rgba_bytes + } + + #[must_use] + pub fn summary(&self) -> &RenderedFrameSummary { + &self.summary + } + + #[must_use] + pub fn opaque_pixel_count(&self) -> u64 { + self.summary.opaque_pixel_count() + } + + #[must_use] + pub fn non_white_pixel_count(&self) -> u64 { + self.summary.non_white_pixel_count() + } + + #[must_use] + pub fn sample_hash(&self) -> u64 { + self.summary.sample_hash() + } +} + #[derive(Clone, Debug, Eq, PartialEq)] pub struct WebViewSnapshot { webview_id: WebViewId, @@ -116,4 +228,6 @@ pub trait ServoHost { fn tick(&mut self) -> bool; fn paint(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError>; + + fn last_rendered_frame(&self) -> Result; } diff --git a/crates/ely_servo_host/src/lib.rs b/crates/ely_servo_host/src/lib.rs index 661795d..8f607e7 100644 --- a/crates/ely_servo_host/src/lib.rs +++ b/crates/ely_servo_host/src/lib.rs @@ -5,8 +5,8 @@ mod runtime; pub use error::ServoHostError; pub use host::{ - NavigationRequest, PermissionDecision, PermissionRequest, ServoHost, WebViewSnapshot, - WebViewState, + NavigationRequest, PermissionDecision, PermissionRequest, RenderedFrame, RenderedFrameSummary, + ServoHost, WebViewSnapshot, WebViewState, }; #[cfg(feature = "servo-engine")] pub use runtime::{ServoSurfaceSize, SoftwareServoHost}; diff --git a/crates/ely_servo_host/src/runtime.rs b/crates/ely_servo_host/src/runtime.rs index 92897bb..497bba1 100644 --- a/crates/ely_servo_host/src/runtime.rs +++ b/crates/ely_servo_host/src/runtime.rs @@ -11,14 +11,14 @@ use std::{ use dpi::PhysicalSize; use ely_domain::{ProfileId, TabId, WebViewId}; use servo::{ - EventLoopWaker, LoadStatus, RenderingContext, Servo, ServoBuilder, WebView, WebViewBuilder, - WebViewDelegate, + DeviceIntPoint, DeviceIntRect, DeviceIntSize, EventLoopWaker, LoadStatus, RenderingContext, + Servo, ServoBuilder, WebView, WebViewBuilder, WebViewDelegate, }; use url::Url; use crate::{ - NavigationRequest, PermissionDecision, PermissionRequest, ServoHost, ServoHostError, - WebViewSnapshot, WebViewState, + NavigationRequest, PermissionDecision, PermissionRequest, RenderedFrame, ServoHost, + ServoHostError, WebViewSnapshot, WebViewState, }; static SERVO_RUNTIME_STARTED: AtomicBool = AtomicBool::new(false); @@ -46,6 +46,7 @@ pub struct SoftwareServoHost { webviews: HashMap, permissions: PermissionStore, wake_requested: Arc, + last_rendered_frame: Option, } impl SoftwareServoHost { @@ -82,6 +83,7 @@ impl SoftwareServoHost { webviews: HashMap::new(), permissions: Rc::new(RefCell::new(HashMap::new())), wake_requested, + last_rendered_frame: None, }) } } @@ -171,16 +173,24 @@ impl ServoHost for SoftwareServoHost { } fn paint(&mut self, webview_id: &WebViewId) -> Result<(), ServoHostError> { - let webview = self.webview(webview_id)?; self.rendering_context .make_current() .map_err(|_| ServoHostError::RenderingContextNotCurrent)?; self.rendering_context.prepare_for_rendering(); - webview.webview.paint(); + { + let webview = self.webview(webview_id)?; + webview.webview.paint(); + } + let rendered_frame = self.read_rendered_frame()?; self.rendering_context.present(); - webview.delegate.mark_frame_presented(); + self.webview(webview_id)?.delegate.mark_frame_presented(); + self.last_rendered_frame = Some(rendered_frame); Ok(()) } + + fn last_rendered_frame(&self) -> Result { + self.last_rendered_frame.clone().ok_or(ServoHostError::RenderedFrameUnavailable) + } } impl SoftwareServoHost { @@ -189,6 +199,24 @@ impl SoftwareServoHost { .get(webview_id) .ok_or_else(|| ServoHostError::WebViewNotFound { id: webview_id.clone() }) } + + fn read_rendered_frame(&self) -> Result { + let size = self.rendering_context.size(); + let width = + i32::try_from(size.width).map_err(|_| ServoHostError::RenderedFrameUnavailable)?; + let height = + i32::try_from(size.height).map_err(|_| ServoHostError::RenderedFrameUnavailable)?; + let frame_rect = DeviceIntRect::from_origin_and_size( + DeviceIntPoint::new(0, 0), + DeviceIntSize::new(width, height), + ); + let image = self + .rendering_context + .read_to_image(frame_rect) + .ok_or(ServoHostError::RenderedFrameUnavailable)?; + + Ok(RenderedFrame::from_rgba_bytes(size.width, size.height, image.into_raw())) + } } struct HostWebView { diff --git a/crates/ely_servo_host/tests/software_host.rs b/crates/ely_servo_host/tests/software_host.rs index 90abcfa..0f1782e 100644 --- a/crates/ely_servo_host/tests/software_host.rs +++ b/crates/ely_servo_host/tests/software_host.rs @@ -7,9 +7,11 @@ use ely_servo_host::{ NavigationRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, WebViewState, }; +const PRD_SITE_COMPATIBILITY_URLS: &[&str] = &["https://example.com", "https://servo.org"]; + #[test] fn manages_real_servo_webview_lifecycle() -> Result<(), Box> { - let mut host = SoftwareServoHost::new(ServoSurfaceSize::new(320, 240))?; + let mut host = SoftwareServoHost::new(ServoSurfaceSize::new(640, 480))?; let tab_id = TabId::new(); let profile_id = ProfileId::new(); @@ -27,24 +29,80 @@ fn manages_real_servo_webview_lifecycle() -> Result<(), Box> { host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?; - for _ in 0..1_000 { - host.tick(); - if host.state(&webview_id)? == WebViewState::Complete { - break; - } - thread::sleep(Duration::from_millis(1)); - } - - let snapshot = host.snapshot(&webview_id)?; + let snapshot = wait_for_rendered_webview(&mut host, &webview_id, None)?; assert_eq!(snapshot.state(), &WebViewState::Complete, "snapshot: {snapshot:?}"); assert!( snapshot.url().is_some_and(|value| value.starts_with("data:text/html,")), "snapshot: {snapshot:?}" ); + assert_rendered_frame_has_content(&host, "data:text/html")?; + + let mut previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash()); + for site_url in PRD_SITE_COMPATIBILITY_URLS { + let tab_id = TabId::new(); + let url = UrlText::parse(*site_url)?; + + host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, url })?; + let snapshot = wait_for_rendered_webview(&mut host, &webview_id, previous_frame_hash)?; + + assert_eq!(snapshot.state(), &WebViewState::Complete, "{site_url}: {snapshot:?}"); + assert!( + snapshot.url().is_some_and(|value| value.starts_with(site_url)), + "{site_url}: {snapshot:?}" + ); + assert_rendered_frame_has_content(&host, site_url)?; + previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash()); + } assert!(matches!( - SoftwareServoHost::new(ServoSurfaceSize::new(320, 240)), + SoftwareServoHost::new(ServoSurfaceSize::new(640, 480)), Err(ServoHostError::RuntimeAlreadyStarted) )); Ok(()) } + +fn wait_for_rendered_webview( + host: &mut SoftwareServoHost, + webview_id: &ely_domain::WebViewId, + previous_frame_hash: Option, +) -> Result> { + let mut painted_since_request = false; + + for _ in 0..5_000 { + host.tick(); + let snapshot = host.snapshot(webview_id)?; + if snapshot.has_pending_frame() { + host.paint(webview_id)?; + painted_since_request = true; + } + + let snapshot = host.snapshot(webview_id)?; + let has_rendered_current_request = host.last_rendered_frame().is_ok_and(|frame| { + painted_since_request + && Some(frame.sample_hash()) != previous_frame_hash + && frame.non_white_pixel_count() > 0 + }); + + if snapshot.state() == &WebViewState::Complete && has_rendered_current_request { + return Ok(snapshot); + } + + thread::sleep(Duration::from_millis(2)); + } + + Err(format!("timed out waiting for rendered webview: {:?}", host.snapshot(webview_id)?).into()) +} + +fn assert_rendered_frame_has_content( + host: &SoftwareServoHost, + label: &str, +) -> Result<(), Box> { + let frame = host.last_rendered_frame()?; + + assert_eq!(frame.width(), 640, "{label}: {frame:?}"); + assert_eq!(frame.height(), 480, "{label}: {frame:?}"); + assert!(frame.opaque_pixel_count() > 0, "{label}: {frame:?}"); + assert!(frame.non_white_pixel_count() > 0, "{label}: {frame:?}"); + assert_ne!(frame.sample_hash(), 0, "{label}: {frame:?}"); + Ok(()) +}