Render PRD sites in real viewport

This commit is contained in:
2026-05-08 13:20:43 -04:00
parent 2fb6b15e2d
commit 8e12d6690b
8 changed files with 477 additions and 118 deletions
+7 -3
View File
@@ -3,8 +3,8 @@ mod shell;
mod shortcuts; mod shortcuts;
use gpui::{ use gpui::{
App, AppContext, Application, Bounds, Focusable, Menu, MenuItem, SystemMenuType, WindowBounds, App, AppContext, Application, Bounds, Focusable, Menu, MenuItem, SystemMenuType,
WindowOptions, actions, px, size, TitlebarOptions, WindowBounds, WindowOptions, actions, point, px, size,
}; };
use gpui_component_assets::Assets; use gpui_component_assets::Assets;
use shell::ElyShell; use shell::ElyShell;
@@ -81,7 +81,11 @@ fn main() {
let bounds = Bounds::centered(None, size(px(1240.0), px(780.0)), cx); let bounds = Bounds::centered(None, size(px(1240.0), px(780.0)), cx);
let opened = cx.open_window( let opened = cx.open_window(
WindowOptions { WindowOptions {
titlebar: None, titlebar: Some(TitlebarOptions {
title: Some("ELY Browser".into()),
appears_transparent: true,
traffic_light_position: Some(point(px(18.0), px(24.0))),
}),
window_bounds: Some(WindowBounds::Windowed(bounds)), window_bounds: Some(WindowBounds::Windowed(bounds)),
..WindowOptions::default() ..WindowOptions::default()
}, },
+59 -7
View File
@@ -1,14 +1,18 @@
use std::{ use std::{
env, fs, io, env, fs, io,
path::{Path, PathBuf}, path::{Path, PathBuf},
process::Command, process::{Command, Output, Stdio},
time::{SystemTime, SystemTimeError, UNIX_EPOCH}, thread,
time::{Duration, Instant, SystemTime, SystemTimeError, UNIX_EPOCH},
}; };
use ely_domain::UrlText; use ely_domain::UrlText;
use serde::Deserialize; use serde::Deserialize;
use thiserror::Error; use thiserror::Error;
const SIDECAR_COMMAND_TIMEOUT: Duration = Duration::from_secs(20);
const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20);
#[derive(Clone, Debug)] #[derive(Clone, Debug)]
pub struct ServoSidecarClient { pub struct ServoSidecarClient {
binary_path: PathBuf, binary_path: PathBuf,
@@ -30,7 +34,13 @@ impl ServoSidecarClient {
} }
let rgba_path = temporary_rgba_path()?; let rgba_path = temporary_rgba_path()?;
let output = self.run_snapshot_command(&request, &rgba_path)?; let output = match self.run_snapshot_command(&request, &rgba_path) {
Ok(output) => output,
Err(error) => {
remove_temporary_file(&rgba_path)?;
return Err(error);
}
};
if !output.status.success() { if !output.status.success() {
remove_temporary_file(&rgba_path)?; remove_temporary_file(&rgba_path)?;
@@ -53,8 +63,8 @@ impl ServoSidecarClient {
&self, &self,
request: &SidecarSnapshotRequest, request: &SidecarSnapshotRequest,
rgba_path: &Path, rgba_path: &Path,
) -> Result<std::process::Output, ServoSidecarError> { ) -> Result<Output, ServoSidecarError> {
Command::new(&self.binary_path) let mut child = Command::new(&self.binary_path)
.arg("snapshot") .arg("snapshot")
.arg("--url") .arg("--url")
.arg(request.url.as_str()) .arg(request.url.as_str())
@@ -64,8 +74,27 @@ impl ServoSidecarClient {
.arg(request.width.to_string()) .arg(request.width.to_string())
.arg("--height") .arg("--height")
.arg(request.height.to_string()) .arg(request.height.to_string())
.output() .stdout(Stdio::piped())
.map_err(ServoSidecarError::Command) .stderr(Stdio::piped())
.spawn()
.map_err(ServoSidecarError::Command)?;
let started_at = Instant::now();
loop {
if child.try_wait().map_err(ServoSidecarError::Command)?.is_some() {
return child.wait_with_output().map_err(ServoSidecarError::Command);
}
if started_at.elapsed() >= SIDECAR_COMMAND_TIMEOUT {
terminate_child(child)?;
return Err(ServoSidecarError::SidecarTimedOut {
url: request.url.as_str().to_string(),
seconds: SIDECAR_COMMAND_TIMEOUT.as_secs(),
});
}
thread::sleep(SIDECAR_POLL_INTERVAL);
}
} }
} }
@@ -111,6 +140,11 @@ impl SidecarSnapshot {
requested_url: report.requested_url, requested_url: report.requested_url,
}); });
} }
if report.content_pixel_count == 0 {
return Err(ServoSidecarError::ContentlessRenderedFrame {
requested_url: report.requested_url,
});
}
Ok(Self { Ok(Self {
loaded_url: report.loaded_url, loaded_url: report.loaded_url,
@@ -170,6 +204,9 @@ pub enum ServoSidecarError {
#[error("servo sidecar exited with {status}: {stderr}")] #[error("servo sidecar exited with {status}: {stderr}")]
SidecarFailed { status: String, stderr: String }, SidecarFailed { status: String, stderr: String },
#[error("servo sidecar timed out after {seconds}s while rendering {url}")]
SidecarTimedOut { url: String, seconds: u64 },
#[error("failed to parse servo sidecar report: {0}")] #[error("failed to parse servo sidecar report: {0}")]
Report(#[from] serde_json::Error), Report(#[from] serde_json::Error),
@@ -192,6 +229,9 @@ pub enum ServoSidecarError {
#[error("servo rendered a blank frame for {requested_url}")] #[error("servo rendered a blank frame for {requested_url}")]
BlankRenderedFrame { requested_url: String }, BlankRenderedFrame { requested_url: String },
#[error("servo rendered a frame without visible content for {requested_url}")]
ContentlessRenderedFrame { requested_url: String },
} }
#[derive(Deserialize)] #[derive(Deserialize)]
@@ -204,6 +244,7 @@ struct SidecarReport {
height: u32, height: u32,
rgba_byte_count: usize, rgba_byte_count: usize,
non_white_pixel_count: u64, non_white_pixel_count: u64,
content_pixel_count: u64,
} }
fn default_sidecar_path() -> Result<PathBuf, ServoSidecarError> { fn default_sidecar_path() -> Result<PathBuf, ServoSidecarError> {
@@ -234,6 +275,17 @@ fn remove_temporary_file(path: &Path) -> Result<(), ServoSidecarError> {
} }
} }
fn terminate_child(mut child: std::process::Child) -> Result<(), ServoSidecarError> {
match child.kill() {
Ok(()) => {
let _output = child.wait_with_output().map_err(ServoSidecarError::Command)?;
Ok(())
}
Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()),
Err(error) => Err(ServoSidecarError::Command(error)),
}
}
fn read_sidecar_snapshot( fn read_sidecar_snapshot(
stdout: &[u8], stdout: &[u8],
rgba_path: &Path, rgba_path: &Path,
+2 -1
View File
@@ -97,7 +97,8 @@ impl ElyShell {
div() div()
.h(px(spacing::COMMAND_BAR_HEIGHT)) .h(px(spacing::COMMAND_BAR_HEIGHT))
.px_4() .pl(px(96.0))
.pr_4()
.gap_3() .gap_3()
.flex() .flex()
.items_center() .items_center()
+215 -69
View File
@@ -2,11 +2,12 @@ use std::{collections::BTreeMap, sync::Arc};
use ely_domain::{BrowserTab, TabId}; use ely_domain::{BrowserTab, TabId};
use gpui::{ use gpui::{
AnyElement, Context, ImageSource, IntoElement, ObjectFit, ParentElement, RenderImage, Styled, AnyElement, App, Bounds, Context, Entity, ImageSource, IntoElement, ObjectFit, ParentElement,
StyledImage, div, img, px, rgb, Pixels, RenderImage, Styled, StyledImage, Window, canvas, div, img, prelude::FluentBuilder, px,
rgb,
}; };
use gpui_component::StyledExt; use gpui_component::StyledExt;
use image::{ImageBuffer, Rgba}; use image::{ImageBuffer, Rgba, imageops::FilterType};
use thiserror::Error; use thiserror::Error;
use crate::services::servo_sidecar::{ use crate::services::servo_sidecar::{
@@ -16,17 +17,21 @@ use crate::services::servo_sidecar::{
use super::ElyShell; use super::ElyShell;
use ely_design_system::{colors, spacing}; use ely_design_system::{colors, spacing};
const WEB_SURFACE_WIDTH: u32 = 1024; const WEB_SURFACE_IMAGE_MAX_EDGE: u32 = 1024;
const WEB_SURFACE_HEIGHT: u32 = 768;
pub(super) struct WebSurfaceStore { pub(super) struct WebSurfaceStore {
client: WebSurfaceClient, client: WebSurfaceClient,
viewport_sizes: BTreeMap<TabId, WebSurfaceSize>,
states: BTreeMap<TabId, WebSurfaceState>, states: BTreeMap<TabId, WebSurfaceState>,
} }
impl WebSurfaceStore { impl WebSurfaceStore {
pub(super) fn new() -> Self { pub(super) fn new() -> Self {
Self { client: WebSurfaceClient::new(), states: BTreeMap::new() } Self {
client: WebSurfaceClient::new(),
viewport_sizes: BTreeMap::new(),
states: BTreeMap::new(),
}
} }
fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> { fn state(&self, tab_id: &TabId) -> Option<&WebSurfaceState> {
@@ -38,8 +43,12 @@ impl WebSurfaceStore {
return None; return None;
} }
let size = self.viewport_sizes.get(tab.id()).copied()?;
let requested_url = tab.url().as_str().to_string(); let requested_url = tab.url().as_str().to_string();
if self.has_current_state(tab.id(), &requested_url) { if self.is_loading_requested_url(tab.id(), requested_url.as_str()) {
return None;
}
if self.has_current_state(tab.id(), &requested_url, size) {
return None; return None;
} }
@@ -48,7 +57,7 @@ impl WebSurfaceStore {
WebSurfaceClient::Unavailable(message) => { WebSurfaceClient::Unavailable(message) => {
self.states.insert( self.states.insert(
tab.id().clone(), tab.id().clone(),
WebSurfaceState::Failed { requested_url, message: message.clone() }, WebSurfaceState::Failed { requested_url, size, message: message.clone() },
); );
return None; return None;
} }
@@ -56,38 +65,51 @@ impl WebSurfaceStore {
self.states.insert( self.states.insert(
tab.id().clone(), tab.id().clone(),
WebSurfaceState::Loading { requested_url: requested_url.clone() }, WebSurfaceState::Loading { requested_url: requested_url.clone(), size },
); );
Some(WebSurfaceRequest { Some(WebSurfaceRequest {
tab_id: tab.id().clone(), tab_id: tab.id().clone(),
requested_url, requested_url,
size,
client, client,
snapshot_request: SidecarSnapshotRequest::new( snapshot_request: SidecarSnapshotRequest::new(
tab.url().clone(), tab.url().clone(),
WEB_SURFACE_WIDTH, size.width,
WEB_SURFACE_HEIGHT, size.height,
), ),
}) })
} }
fn has_current_state(&self, tab_id: &TabId, requested_url: &str) -> bool { fn has_current_state(&self, tab_id: &TabId, requested_url: &str, size: WebSurfaceSize) -> bool {
match self.states.get(tab_id) { match self.states.get(tab_id) {
Some(WebSurfaceState::Loading { requested_url: current_url }) => { Some(WebSurfaceState::Loading { requested_url: current_url, size: current_size }) => {
current_url == requested_url current_url == requested_url && *current_size == size
} }
Some(WebSurfaceState::Ready(frame)) => frame.requested_url == requested_url, Some(WebSurfaceState::Ready(frame)) => {
Some(WebSurfaceState::Failed { requested_url: current_url, .. }) => { frame.requested_url == requested_url && frame.size() == size
current_url == requested_url
} }
Some(WebSurfaceState::Failed {
requested_url: current_url,
size: current_size,
..
}) => current_url == requested_url && *current_size == size,
None => false, None => false,
} }
} }
fn is_loading(&self, tab_id: &TabId, requested_url: &str) -> bool { fn is_loading(&self, tab_id: &TabId, requested_url: &str, size: WebSurfaceSize) -> bool {
matches!( matches!(
self.states.get(tab_id), self.states.get(tab_id),
Some(WebSurfaceState::Loading { requested_url: current_url }) Some(WebSurfaceState::Loading { requested_url: current_url, size: current_size })
if current_url == requested_url && *current_size == size
)
}
fn is_loading_requested_url(&self, tab_id: &TabId, requested_url: &str) -> bool {
matches!(
self.states.get(tab_id),
Some(WebSurfaceState::Loading { requested_url: current_url, .. })
if current_url == requested_url if current_url == requested_url
) )
} }
@@ -95,6 +117,19 @@ impl WebSurfaceStore {
fn finish(&mut self, tab_id: TabId, state: WebSurfaceState) { fn finish(&mut self, tab_id: TabId, state: WebSurfaceState) {
self.states.insert(tab_id, state); self.states.insert(tab_id, state);
} }
fn record_viewport_size(&mut self, tab_id: &TabId, bounds: Bounds<Pixels>) -> bool {
let Some(size) = WebSurfaceSize::from_bounds(bounds) else {
return false;
};
if self.viewport_sizes.get(tab_id) == Some(&size) {
return false;
}
self.viewport_sizes.insert(tab_id.clone(), size);
true
}
} }
enum WebSurfaceClient { enum WebSurfaceClient {
@@ -114,14 +149,30 @@ impl WebSurfaceClient {
struct WebSurfaceRequest { struct WebSurfaceRequest {
tab_id: TabId, tab_id: TabId,
requested_url: String, requested_url: String,
size: WebSurfaceSize,
client: ServoSidecarClient, client: ServoSidecarClient,
snapshot_request: SidecarSnapshotRequest, snapshot_request: SidecarSnapshotRequest,
} }
enum WebSurfaceState { enum WebSurfaceState {
Loading { requested_url: String }, Loading { requested_url: String, size: WebSurfaceSize },
Ready(WebSurfaceFrame), Ready(WebSurfaceFrame),
Failed { requested_url: String, message: String }, Failed { requested_url: String, size: WebSurfaceSize, message: String },
}
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
struct WebSurfaceSize {
width: u32,
height: u32,
}
impl WebSurfaceSize {
fn from_bounds(bounds: Bounds<Pixels>) -> Option<Self> {
Some(Self {
width: viewport_dimension(bounds.size.width)?,
height: viewport_dimension(bounds.size.height)?,
})
}
} }
struct WebSurfaceFrame { struct WebSurfaceFrame {
@@ -148,13 +199,15 @@ impl WebSurfaceFrame {
return Err(WebSurfaceError::InvalidFrameBuffer { width, height }); return Err(WebSurfaceError::InvalidFrameBuffer { width, height });
}; };
let image_buffer = renderable_image_buffer(buffer);
Ok(Self { Ok(Self {
requested_url, requested_url,
loaded_url, loaded_url,
title, title,
width, width,
height, height,
image: Arc::new(RenderImage::new([image::Frame::new(buffer)])), image: Arc::new(RenderImage::new([image::Frame::new(image_buffer)])),
}) })
} }
@@ -165,6 +218,10 @@ impl WebSurfaceFrame {
fn url_label(&self) -> &str { fn url_label(&self) -> &str {
self.loaded_url.as_deref().unwrap_or(self.requested_url.as_str()) self.loaded_url.as_deref().unwrap_or(self.requested_url.as_str())
} }
fn size(&self) -> WebSurfaceSize {
WebSurfaceSize { width: self.width, height: self.height }
}
} }
#[derive(Debug, Error)] #[derive(Debug, Error)]
@@ -181,12 +238,17 @@ impl ElyShell {
) -> AnyElement { ) -> AnyElement {
self.ensure_external_web_frame(tab, cx); self.ensure_external_web_frame(tab, cx);
let state_entity = cx.entity().clone();
match self.web_surfaces.state(tab.id()) { match self.web_surfaces.state(tab.id()) {
Some(WebSurfaceState::Ready(frame)) => render_ready_web_surface(frame), Some(WebSurfaceState::Ready(frame)) => {
render_ready_web_surface(frame, tab, state_entity)
}
Some(WebSurfaceState::Failed { message, .. }) => { Some(WebSurfaceState::Failed { message, .. }) => {
render_failed_web_surface(tab, message.as_str()) render_failed_web_surface(tab, message.as_str(), state_entity)
}
Some(WebSurfaceState::Loading { .. }) | None => {
render_loading_web_surface(tab, state_entity)
} }
Some(WebSurfaceState::Loading { .. }) | None => render_loading_web_surface(tab),
} }
} }
@@ -195,7 +257,7 @@ impl ElyShell {
return; return;
}; };
let WebSurfaceRequest { tab_id, requested_url, client, snapshot_request } = request; let WebSurfaceRequest { tab_id, requested_url, size, client, snapshot_request } = request;
cx.spawn(async move |shell, cx| { cx.spawn(async move |shell, cx| {
let result = cx let result = cx
.background_executor() .background_executor()
@@ -203,7 +265,7 @@ impl ElyShell {
.await; .await;
_ = shell.update(cx, |shell, cx| { _ = shell.update(cx, |shell, cx| {
shell.handle_external_web_frame_result(tab_id, requested_url, result); shell.handle_external_web_frame_result(tab_id, requested_url, size, result);
cx.notify(); cx.notify();
}); });
}) })
@@ -214,42 +276,55 @@ impl ElyShell {
&mut self, &mut self,
tab_id: TabId, tab_id: TabId,
requested_url: String, requested_url: String,
size: WebSurfaceSize,
result: Result<SidecarSnapshot, ServoSidecarError>, result: Result<SidecarSnapshot, ServoSidecarError>,
) { ) {
if !self.web_surfaces.is_loading(&tab_id, requested_url.as_str()) { if !self.web_surfaces.is_loading(&tab_id, requested_url.as_str(), size) {
return; return;
} }
let state = match result { let state = match result {
Ok(snapshot) => match WebSurfaceFrame::from_snapshot(requested_url.clone(), snapshot) { Ok(snapshot) => match WebSurfaceFrame::from_snapshot(requested_url.clone(), snapshot) {
Ok(frame) => WebSurfaceState::Ready(frame), Ok(frame) => WebSurfaceState::Ready(frame),
Err(error) => WebSurfaceState::Failed { requested_url, message: error.to_string() }, Err(error) => {
WebSurfaceState::Failed { requested_url, size, message: error.to_string() }
}
}, },
Err(error) => WebSurfaceState::Failed { requested_url, message: error.to_string() }, Err(error) => {
WebSurfaceState::Failed { requested_url, size, message: error.to_string() }
}
}; };
self.web_surfaces.finish(tab_id, state); self.web_surfaces.finish(tab_id, state);
} }
fn record_external_web_viewport(
&mut self,
tab_id: TabId,
bounds: Bounds<Pixels>,
cx: &mut Context<Self>,
) {
if self.web_surfaces.record_viewport_size(&tab_id, bounds) {
cx.notify();
}
}
} }
fn render_ready_web_surface(frame: &WebSurfaceFrame) -> AnyElement { fn render_ready_web_surface(
frame: &WebSurfaceFrame,
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
) -> AnyElement {
render_web_surface( render_web_surface(
div() tab,
.size_full() state_entity,
.flex() frame.title_label(),
.flex_col() frame.url_label().to_string(),
.bg(rgb(colors::SURFACE_CARD)) Some(format!("{}x{}", frame.width, frame.height)),
.child(render_web_surface_header(frame)) img(ImageSource::Render(frame.image.clone())).size_full().object_fit(ObjectFit::Contain),
.child(
div().flex_1().min_h_0().overflow_hidden().bg(rgb(colors::SURFACE_CARD)).child(
img(ImageSource::Render(frame.image.clone()))
.size_full()
.object_fit(ObjectFit::Contain),
),
),
) )
} }
fn render_web_surface_header(frame: &WebSurfaceFrame) -> AnyElement { fn render_web_surface_header(title: String, url: String, detail: Option<String>) -> AnyElement {
div() div()
.h(px(34.0)) .h(px(34.0))
.px_3() .px_3()
@@ -267,36 +342,41 @@ fn render_web_surface_header(frame: &WebSurfaceFrame) -> AnyElement {
.text_sm() .text_sm()
.font_semibold() .font_semibold()
.text_color(rgb(colors::INK)) .text_color(rgb(colors::INK))
.child(frame.title_label()), .child(title),
) )
.when_some(detail, |this, detail| {
this.child(div().text_xs().text_color(rgb(colors::MUTED)).child(detail))
})
.child( .child(
div() div().max_w(px(420.0)).truncate().text_xs().text_color(rgb(colors::MUTED)).child(url),
.text_xs()
.text_color(rgb(colors::MUTED))
.child(format!("{}x{}", frame.width, frame.height)),
)
.child(
div()
.max_w(px(420.0))
.truncate()
.text_xs()
.text_color(rgb(colors::MUTED))
.child(frame.url_label().to_string()),
) )
.into_any_element() .into_any_element()
} }
fn render_loading_web_surface(tab: &BrowserTab) -> AnyElement { fn render_loading_web_surface(tab: &BrowserTab, state_entity: Entity<ElyShell>) -> AnyElement {
render_web_surface(centered_status( render_web_surface(
tab.title(), tab,
tab.url().as_str(), state_entity,
"Rendering page with Servo", tab.title().to_string(),
colors::BODY, tab.url().as_str().to_string(),
)) Some("Rendering".to_string()),
centered_status(tab.title(), tab.url().as_str(), "Rendering page with Servo", colors::BODY),
)
} }
fn render_failed_web_surface(tab: &BrowserTab, message: &str) -> AnyElement { fn render_failed_web_surface(
render_web_surface(centered_status(tab.title(), tab.url().as_str(), message, colors::ERROR)) tab: &BrowserTab,
message: &str,
state_entity: Entity<ElyShell>,
) -> AnyElement {
render_web_surface(
tab,
state_entity,
tab.title().to_string(),
tab.url().as_str().to_string(),
Some("Render failed".to_string()),
centered_status(tab.title(), tab.url().as_str(), message, colors::ERROR),
)
} }
fn centered_status(title: &str, url: &str, detail: &str, detail_color: u32) -> impl IntoElement { fn centered_status(title: &str, url: &str, detail: &str, detail_color: u32) -> impl IntoElement {
@@ -314,7 +394,14 @@ fn centered_status(title: &str, url: &str, detail: &str, detail_color: u32) -> i
.child(div().text_sm().text_color(rgb(detail_color)).child(detail.to_string())) .child(div().text_sm().text_color(rgb(detail_color)).child(detail.to_string()))
} }
fn render_web_surface(content: impl IntoElement) -> AnyElement { fn render_web_surface(
tab: &BrowserTab,
state_entity: Entity<ElyShell>,
title: String,
url: String,
detail: Option<String>,
content: impl IntoElement,
) -> AnyElement {
div() div()
.flex_1() .flex_1()
.h_full() .h_full()
@@ -328,11 +415,70 @@ fn render_web_surface(content: impl IntoElement) -> AnyElement {
.border_1() .border_1()
.border_color(rgb(colors::HAIRLINE)) .border_color(rgb(colors::HAIRLINE))
.bg(rgb(colors::SURFACE_CARD)) .bg(rgb(colors::SURFACE_CARD))
.child(content), .flex()
.flex_col()
.child(render_web_surface_header(title, url, detail))
.child(
div()
.relative()
.flex_1()
.min_h_0()
.overflow_hidden()
.bg(rgb(colors::SURFACE_CARD))
.child(content)
.child(render_viewport_tracker(tab.id().clone(), state_entity)),
),
) )
.into_any_element() .into_any_element()
} }
fn render_viewport_tracker(tab_id: TabId, state_entity: Entity<ElyShell>) -> impl IntoElement {
canvas(
move |bounds, _window: &mut Window, cx: &mut App| {
state_entity.update(cx, |shell, cx| {
shell.record_external_web_viewport(tab_id, bounds, cx);
});
},
|_, _, _, _| {},
)
.absolute()
.size_full()
}
fn viewport_dimension(pixels: Pixels) -> Option<u32> {
let value = f32::from(pixels.round());
if !value.is_finite() || value < 1.0 || value > u32::MAX as f32 {
return None;
}
Some(value as u32)
}
fn renderable_image_buffer(
buffer: ImageBuffer<Rgba<u8>, Vec<u8>>,
) -> ImageBuffer<Rgba<u8>, Vec<u8>> {
let largest_edge = buffer.width().max(buffer.height());
if largest_edge <= WEB_SURFACE_IMAGE_MAX_EDGE {
return buffer;
}
image::imageops::resize(
&buffer,
scaled_image_dimension(buffer.width(), largest_edge),
scaled_image_dimension(buffer.height(), largest_edge),
FilterType::Triangle,
)
}
fn scaled_image_dimension(dimension: u32, largest_edge: u32) -> u32 {
let numerator = u64::from(dimension) * u64::from(WEB_SURFACE_IMAGE_MAX_EDGE);
let rounded = (numerator + u64::from(largest_edge / 2)) / u64::from(largest_edge);
match u32::try_from(rounded.max(1)) {
Ok(value) => value,
Err(_) => WEB_SURFACE_IMAGE_MAX_EDGE,
}
}
pub(super) fn is_external_web_url(url: &str) -> bool { pub(super) fn is_external_web_url(url: &str) -> bool {
url.starts_with("https://") || url.starts_with("http://") url.starts_with("https://") || url.starts_with("http://")
} }
@@ -1,4 +1,10 @@
use std::{env, num::ParseIntError, path::PathBuf, thread, time::Duration}; use std::{
env,
num::ParseIntError,
path::PathBuf,
thread,
time::{Duration, Instant},
};
use ely_domain::{ProfileId, TabId, UrlText}; use ely_domain::{ProfileId, TabId, UrlText};
use ely_servo_host::{ use ely_servo_host::{
@@ -10,6 +16,7 @@ use thiserror::Error;
const WAIT_ITERATIONS: usize = 5_000; const WAIT_ITERATIONS: usize = 5_000;
const WAIT_INTERVAL: Duration = Duration::from_millis(2); const WAIT_INTERVAL: Duration = Duration::from_millis(2);
const RENDER_TIMEOUT: Duration = Duration::from_secs(20);
fn main() -> Result<(), SidecarError> { fn main() -> Result<(), SidecarError> {
match parse_command(env::args())? { match parse_command(env::args())? {
@@ -175,7 +182,12 @@ fn wait_for_frame(
webview_id: &ely_domain::WebViewId, webview_id: &ely_domain::WebViewId,
url: &str, url: &str,
) -> Result<WebViewSnapshot, SidecarError> { ) -> Result<WebViewSnapshot, SidecarError> {
let started_at = Instant::now();
for _ in 0..WAIT_ITERATIONS { for _ in 0..WAIT_ITERATIONS {
if started_at.elapsed() >= RENDER_TIMEOUT {
break;
}
host.tick(); host.tick();
let snapshot = host.snapshot(webview_id)?; let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() { if snapshot.has_pending_frame() {
@@ -210,6 +222,7 @@ struct SnapshotReport {
rgba_byte_count: usize, rgba_byte_count: usize,
opaque_pixel_count: u64, opaque_pixel_count: u64,
non_white_pixel_count: u64, non_white_pixel_count: u64,
content_pixel_count: u64,
sample_hash: u64, sample_hash: u64,
} }
@@ -231,6 +244,7 @@ impl SnapshotReport {
rgba_byte_count: frame.rgba_bytes().len(), rgba_byte_count: frame.rgba_bytes().len(),
opaque_pixel_count: frame.opaque_pixel_count(), opaque_pixel_count: frame.opaque_pixel_count(),
non_white_pixel_count: frame.non_white_pixel_count(), non_white_pixel_count: frame.non_white_pixel_count(),
content_pixel_count: frame.content_pixel_count(),
sample_hash: frame.sample_hash(), sample_hash: frame.sample_hash(),
} }
} }
+23 -1
View File
@@ -17,6 +17,7 @@ pub struct RenderedFrameSummary {
height: u32, height: u32,
opaque_pixel_count: u64, opaque_pixel_count: u64,
non_white_pixel_count: u64, non_white_pixel_count: u64,
content_pixel_count: u64,
sample_hash: u64, sample_hash: u64,
} }
@@ -25,6 +26,7 @@ impl RenderedFrameSummary {
pub fn from_rgba_bytes(width: u32, height: u32, rgba_bytes: &[u8]) -> Self { pub fn from_rgba_bytes(width: u32, height: u32, rgba_bytes: &[u8]) -> Self {
let mut opaque_pixel_count = 0; let mut opaque_pixel_count = 0;
let mut non_white_pixel_count = 0; let mut non_white_pixel_count = 0;
let mut content_pixel_count = 0;
let mut sample_hash = 0xcbf29ce484222325_u64; let mut sample_hash = 0xcbf29ce484222325_u64;
for (index, pixel) in rgba_bytes.chunks_exact(4).enumerate() { for (index, pixel) in rgba_bytes.chunks_exact(4).enumerate() {
@@ -35,6 +37,9 @@ impl RenderedFrameSummary {
if alpha > 0 && (red < 245 || green < 245 || blue < 245) { if alpha > 0 && (red < 245 || green < 245 || blue < 245) {
non_white_pixel_count += 1; non_white_pixel_count += 1;
} }
if alpha > 0 && (red < 220 || green < 220 || blue < 220) {
content_pixel_count += 1;
}
if index % 97 == 0 { if index % 97 == 0 {
for byte in pixel { for byte in pixel {
sample_hash ^= u64::from(*byte); sample_hash ^= u64::from(*byte);
@@ -43,7 +48,14 @@ impl RenderedFrameSummary {
} }
} }
Self { width, height, opaque_pixel_count, non_white_pixel_count, sample_hash } Self {
width,
height,
opaque_pixel_count,
non_white_pixel_count,
content_pixel_count,
sample_hash,
}
} }
#[must_use] #[must_use]
@@ -66,6 +78,11 @@ impl RenderedFrameSummary {
self.non_white_pixel_count self.non_white_pixel_count
} }
#[must_use]
pub fn content_pixel_count(&self) -> u64 {
self.content_pixel_count
}
#[must_use] #[must_use]
pub fn sample_hash(&self) -> u64 { pub fn sample_hash(&self) -> u64 {
self.sample_hash self.sample_hash
@@ -117,6 +134,11 @@ impl RenderedFrame {
self.summary.non_white_pixel_count() self.summary.non_white_pixel_count()
} }
#[must_use]
pub fn content_pixel_count(&self) -> u64 {
self.summary.content_pixel_count()
}
#[must_use] #[must_use]
pub fn sample_hash(&self) -> u64 { pub fn sample_hash(&self) -> u64 {
self.summary.sample_hash() self.summary.sample_hash()
+131 -28
View File
@@ -1,64 +1,167 @@
#![cfg(feature = "servo-engine")] #![cfg(feature = "servo-engine")]
use std::{error::Error, process::Command}; use std::{
error::Error,
io,
process::{Child, Command, Output, Stdio},
thread,
time::{Duration, Instant},
};
const WIDTH: u64 = 640; const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
const HEIGHT: u64 = 480; const SIDECAR_TIMEOUT: Duration = Duration::from_secs(25);
const PRD_SITE_COMPATIBILITY_URLS: &[&str] = &["https://example.com", "https://servo.org"]; const SIDECAR_POLL_INTERVAL: Duration = Duration::from_millis(20);
const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
PrdSiteCompatibilityCase { url: "https://example.com", title_fragment: "Example Domain" },
PrdSiteCompatibilityCase { url: "https://servo.org", title_fragment: "Servo" },
];
const PRD_SITE_COMPATIBILITY_SIZES: &[FrameSize] = &[
FrameSize { width: 640, height: 480 },
FrameSize { width: 934, height: 657 },
FrameSize { width: 1614, height: 980 },
];
struct PrdSiteCompatibilityCase {
url: &'static str,
title_fragment: &'static str,
}
#[derive(Clone, Copy)]
struct FrameSize {
width: u64,
height: u64,
}
#[test] #[test]
fn sidecar_snapshots_prd_sites_to_rgba_files() -> Result<(), Box<dyn Error>> { fn sidecar_snapshots_prd_sites_to_rgba_files() -> Result<(), Box<dyn Error>> {
for site_url in PRD_SITE_COMPATIBILITY_URLS { for case in PRD_SITE_COMPATIBILITY_CASES {
snapshot_prd_site(site_url)?; for size in PRD_SITE_COMPATIBILITY_SIZES {
snapshot_prd_site(case, *size)?;
}
} }
Ok(()) Ok(())
} }
fn snapshot_prd_site(site_url: &str) -> Result<(), Box<dyn Error>> { fn snapshot_prd_site(
let site_name = site_url case: &PrdSiteCompatibilityCase,
size: FrameSize,
) -> Result<(), Box<dyn Error>> {
let site_name = case
.url
.chars() .chars()
.map(|character| if character.is_ascii_alphanumeric() { character } else { '-' }) .map(|character| if character.is_ascii_alphanumeric() { character } else { '-' })
.collect::<String>(); .collect::<String>();
let output_path = std::env::temp_dir() let output_path = std::env::temp_dir().join(format!(
.join(format!("ely-servo-sidecar-{}-{site_name}.rgba", std::process::id())); "ely-servo-sidecar-{}-{site_name}-{}x{}.rgba",
std::process::id(),
size.width,
size.height
));
if output_path.exists() { if output_path.exists() {
std::fs::remove_file(&output_path)?; std::fs::remove_file(&output_path)?;
} }
let output = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar")) let output = run_sidecar_snapshot(case.url, &output_path, size)?;
.arg("snapshot")
.arg("--url")
.arg(site_url)
.arg("--rgba-out")
.arg(&output_path)
.arg("--width")
.arg(WIDTH.to_string())
.arg("--height")
.arg(HEIGHT.to_string())
.output()?;
assert!( assert!(
output.status.success(), output.status.success(),
"{site_url}\nstatus: {:?}\nstdout: {}\nstderr: {}", "{} {}x{}\nstatus: {:?}\nstdout: {}\nstderr: {}",
case.url,
size.width,
size.height,
output.status.code(), output.status.code(),
String::from_utf8_lossy(&output.stdout), String::from_utf8_lossy(&output.stdout),
String::from_utf8_lossy(&output.stderr) String::from_utf8_lossy(&output.stderr)
); );
let report: serde_json::Value = serde_json::from_slice(&output.stdout)?; let report: serde_json::Value = serde_json::from_slice(&output.stdout)?;
assert_eq!(report_field_as_u64(&report, "width")?, WIDTH, "{site_url}"); assert_eq!(report_field_as_u64(&report, "width")?, size.width, "{}", case.url);
assert_eq!(report_field_as_u64(&report, "height")?, HEIGHT, "{site_url}"); assert_eq!(report_field_as_u64(&report, "height")?, size.height, "{}", case.url);
assert_eq!(report_field_as_u64(&report, "rgba_byte_count")?, WIDTH * HEIGHT * 4, "{site_url}"); assert_eq!(
assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0, "{site_url}"); report_field_as_u64(&report, "rgba_byte_count")?,
assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{site_url}"); size.width * size.height * 4,
assert_eq!(std::fs::metadata(&output_path)?.len(), WIDTH * HEIGHT * 4); "{}",
case.url
);
assert_report_text_contains(&report, "loaded_url", case.url)?;
assert_report_text_contains(&report, "title", case.title_fragment)?;
assert!(report_field_as_u64(&report, "non_white_pixel_count")? > 0, "{}", case.url);
assert!(
report_field_as_u64(&report, "content_pixel_count")? >= MINIMUM_CONTENT_PIXELS,
"{}",
case.url
);
assert!(report_field_as_u64(&report, "sample_hash")? > 0, "{}", case.url);
assert_eq!(std::fs::metadata(&output_path)?.len(), size.width * size.height * 4);
std::fs::remove_file(&output_path)?; std::fs::remove_file(&output_path)?;
Ok(()) Ok(())
} }
fn run_sidecar_snapshot(
site_url: &str,
output_path: &std::path::Path,
size: FrameSize,
) -> Result<Output, Box<dyn Error>> {
let mut child = Command::new(env!("CARGO_BIN_EXE_ely_servo_sidecar"))
.arg("snapshot")
.arg("--url")
.arg(site_url)
.arg("--rgba-out")
.arg(output_path)
.arg("--width")
.arg(size.width.to_string())
.arg("--height")
.arg(size.height.to_string())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
let started_at = Instant::now();
loop {
if child.try_wait()?.is_some() {
return child.wait_with_output().map_err(Into::into);
}
if started_at.elapsed() >= SIDECAR_TIMEOUT {
terminate_child(child)?;
return Err(format!(
"timed out rendering {site_url} at {}x{}",
size.width, size.height
)
.into());
}
thread::sleep(SIDECAR_POLL_INTERVAL);
}
}
fn terminate_child(mut child: Child) -> Result<(), Box<dyn Error>> {
match child.kill() {
Ok(()) => {
let _output = child.wait_with_output()?;
Ok(())
}
Err(error) if error.kind() == io::ErrorKind::InvalidInput => Ok(()),
Err(error) => Err(error.into()),
}
}
fn assert_report_text_contains(
report: &serde_json::Value,
field: &'static str,
fragment: &str,
) -> Result<(), Box<dyn Error>> {
let value = report
.get(field)
.and_then(serde_json::Value::as_str)
.ok_or_else(|| format!("missing text report field: {field}"))?;
assert!(value.contains(fragment), "{field}: {value}");
Ok(())
}
fn report_field_as_u64( fn report_field_as_u64(
report: &serde_json::Value, report: &serde_json::Value,
field: &'static str, field: &'static str,
+25 -8
View File
@@ -7,7 +7,16 @@ use ely_servo_host::{
NavigationRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, WebViewState, NavigationRequest, ServoHost, ServoHostError, ServoSurfaceSize, SoftwareServoHost, WebViewState,
}; };
const PRD_SITE_COMPATIBILITY_URLS: &[&str] = &["https://example.com", "https://servo.org"]; const MINIMUM_CONTENT_PIXELS: u64 = 1_000;
const PRD_SITE_COMPATIBILITY_CASES: &[PrdSiteCompatibilityCase] = &[
PrdSiteCompatibilityCase { url: "https://example.com", title_fragment: "Example Domain" },
PrdSiteCompatibilityCase { url: "https://servo.org", title_fragment: "Servo" },
];
struct PrdSiteCompatibilityCase {
url: &'static str,
title_fragment: &'static str,
}
#[test] #[test]
fn manages_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> { fn manages_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
@@ -35,22 +44,28 @@ fn manages_real_servo_webview_lifecycle() -> Result<(), Box<dyn Error>> {
snapshot.url().is_some_and(|value| value.starts_with("data:text/html,")), snapshot.url().is_some_and(|value| value.starts_with("data:text/html,")),
"snapshot: {snapshot:?}" "snapshot: {snapshot:?}"
); );
assert_rendered_frame_has_content(&host, "data:text/html")?; assert_rendered_frame_has_content(&host, "data:text/html", 1)?;
let mut previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash()); let mut previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash());
for site_url in PRD_SITE_COMPATIBILITY_URLS { for site in PRD_SITE_COMPATIBILITY_CASES {
let tab_id = TabId::new(); let tab_id = TabId::new();
let url = UrlText::parse(*site_url)?; let url = UrlText::parse(site.url)?;
host.navigate(NavigationRequest { webview_id: webview_id.clone(), tab_id, 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)?; let snapshot = wait_for_rendered_webview(&mut host, &webview_id, previous_frame_hash)?;
assert_eq!(snapshot.state(), &WebViewState::Complete, "{site_url}: {snapshot:?}"); assert_eq!(snapshot.state(), &WebViewState::Complete, "{}: {snapshot:?}", site.url);
assert!( assert!(
snapshot.url().is_some_and(|value| value.starts_with(site_url)), snapshot.url().is_some_and(|value| value.starts_with(site.url)),
"{site_url}: {snapshot:?}" "{}: {snapshot:?}",
site.url
); );
assert_rendered_frame_has_content(&host, site_url)?; assert!(
snapshot.title().is_some_and(|value| value.contains(site.title_fragment)),
"{}: {snapshot:?}",
site.url
);
assert_rendered_frame_has_content(&host, site.url, MINIMUM_CONTENT_PIXELS)?;
previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash()); previous_frame_hash = Some(host.last_rendered_frame()?.sample_hash());
} }
@@ -96,6 +111,7 @@ fn wait_for_rendered_webview(
fn assert_rendered_frame_has_content( fn assert_rendered_frame_has_content(
host: &SoftwareServoHost, host: &SoftwareServoHost,
label: &str, label: &str,
minimum_content_pixels: u64,
) -> Result<(), Box<dyn Error>> { ) -> Result<(), Box<dyn Error>> {
let frame = host.last_rendered_frame()?; let frame = host.last_rendered_frame()?;
@@ -103,6 +119,7 @@ fn assert_rendered_frame_has_content(
assert_eq!(frame.height(), 480, "{label}: {frame:?}"); assert_eq!(frame.height(), 480, "{label}: {frame:?}");
assert!(frame.opaque_pixel_count() > 0, "{label}: {frame:?}"); assert!(frame.opaque_pixel_count() > 0, "{label}: {frame:?}");
assert!(frame.non_white_pixel_count() > 0, "{label}: {frame:?}"); assert!(frame.non_white_pixel_count() > 0, "{label}: {frame:?}");
assert!(frame.content_pixel_count() >= minimum_content_pixels, "{label}: {frame:?}");
assert_ne!(frame.sample_hash(), 0, "{label}: {frame:?}"); assert_ne!(frame.sample_hash(), 0, "{label}: {frame:?}");
Ok(()) Ok(())
} }