Add Servo sidecar snapshot command

This commit is contained in:
2026-05-08 12:17:41 -04:00
parent 5f0773a043
commit fcdc2de076
6 changed files with 318 additions and 1 deletions
@@ -0,0 +1,247 @@
use std::{env, num::ParseIntError, path::PathBuf, thread, time::Duration};
use ely_domain::{ProfileId, TabId, UrlText};
use ely_servo_host::{
NavigationRequest, RenderedFrame, ServoHost, ServoHostError, ServoSurfaceSize,
SoftwareServoHost, WebViewSnapshot, WebViewState,
};
use serde::Serialize;
use thiserror::Error;
const WAIT_ITERATIONS: usize = 5_000;
const WAIT_INTERVAL: Duration = Duration::from_millis(2);
fn main() -> Result<(), SidecarError> {
match parse_command(env::args())? {
SidecarCommand::Snapshot(args) => run_snapshot(args),
}
}
enum SidecarCommand {
Snapshot(SnapshotArgs),
}
struct SnapshotArgs {
url: UrlText,
rgba_out: PathBuf,
width: u32,
height: u32,
}
#[derive(Debug, Error)]
enum SidecarError {
#[error("missing sidecar command")]
MissingCommand,
#[error("unknown sidecar command: {value}")]
UnknownCommand { value: String },
#[error("missing argument value for {name}")]
MissingArgumentValue { name: &'static str },
#[error("missing required argument: {name}")]
MissingRequiredArgument { name: &'static str },
#[error("unknown argument: {value}")]
UnknownArgument { value: String },
#[error("{name} must be a positive integer: {value}")]
InvalidInteger {
name: &'static str,
value: String,
#[source]
source: ParseIntError,
},
#[error("{name} must be greater than zero")]
ZeroDimension { name: &'static str },
#[error("rgba output path is empty")]
EmptyRgbaOutputPath,
#[error("timed out rendering {url}: {snapshot:?}")]
RenderTimeout { url: String, snapshot: Box<WebViewSnapshot> },
#[error(transparent)]
Domain(#[from] ely_domain::DomainError),
#[error(transparent)]
Host(#[from] ServoHostError),
#[error(transparent)]
Io(#[from] std::io::Error),
#[error(transparent)]
Json(#[from] serde_json::Error),
}
fn parse_command(args: impl IntoIterator<Item = String>) -> Result<SidecarCommand, SidecarError> {
let mut args = args.into_iter();
let _program_name = args.next();
let command = args.next().ok_or(SidecarError::MissingCommand)?;
match command.as_str() {
"snapshot" => parse_snapshot_args(args).map(SidecarCommand::Snapshot),
_ => Err(SidecarError::UnknownCommand { value: command }),
}
}
fn parse_snapshot_args(
args: impl IntoIterator<Item = String>,
) -> Result<SnapshotArgs, SidecarError> {
let mut args = args.into_iter();
let mut url = None;
let mut rgba_out = None;
let mut width = None;
let mut height = None;
while let Some(name) = args.next() {
match name.as_str() {
"--url" => url = Some(UrlText::parse(next_argument(&mut args, "--url")?)?),
"--rgba-out" => {
rgba_out = Some(parse_output_path(next_argument(&mut args, "--rgba-out")?)?)
}
"--width" => {
width = Some(parse_dimension("--width", next_argument(&mut args, "--width")?)?)
}
"--height" => {
height = Some(parse_dimension("--height", next_argument(&mut args, "--height")?)?)
}
_ => return Err(SidecarError::UnknownArgument { value: name }),
}
}
Ok(SnapshotArgs {
url: url.ok_or(SidecarError::MissingRequiredArgument { name: "--url" })?,
rgba_out: rgba_out.ok_or(SidecarError::MissingRequiredArgument { name: "--rgba-out" })?,
width: width.ok_or(SidecarError::MissingRequiredArgument { name: "--width" })?,
height: height.ok_or(SidecarError::MissingRequiredArgument { name: "--height" })?,
})
}
fn next_argument(
args: &mut impl Iterator<Item = String>,
name: &'static str,
) -> Result<String, SidecarError> {
args.next().ok_or(SidecarError::MissingArgumentValue { name })
}
fn parse_dimension(name: &'static str, value: String) -> Result<u32, SidecarError> {
let dimension = value.parse::<u32>().map_err(|source| SidecarError::InvalidInteger {
name,
value,
source,
})?;
if dimension == 0 {
return Err(SidecarError::ZeroDimension { name });
}
Ok(dimension)
}
fn parse_output_path(value: String) -> Result<PathBuf, SidecarError> {
if value.trim().is_empty() {
return Err(SidecarError::EmptyRgbaOutputPath);
}
Ok(PathBuf::from(value))
}
fn run_snapshot(args: SnapshotArgs) -> Result<(), SidecarError> {
let mut host = SoftwareServoHost::new(ServoSurfaceSize::new(args.width, args.height))?;
let tab_id = TabId::new();
let profile_id = ProfileId::new();
let webview_id = host.create_webview(tab_id.clone(), profile_id)?;
host.navigate(NavigationRequest {
webview_id: webview_id.clone(),
tab_id,
url: args.url.clone(),
})?;
let snapshot = wait_for_frame(&mut host, &webview_id, args.url.as_str())?;
let frame = host.last_rendered_frame()?;
std::fs::write(&args.rgba_out, frame.rgba_bytes())?;
serde_json::to_writer(
std::io::stdout().lock(),
&SnapshotReport::new(args.url.as_str(), &args.rgba_out, &snapshot, &frame),
)?;
Ok(())
}
fn wait_for_frame(
host: &mut SoftwareServoHost,
webview_id: &ely_domain::WebViewId,
url: &str,
) -> Result<WebViewSnapshot, SidecarError> {
for _ in 0..WAIT_ITERATIONS {
host.tick();
let snapshot = host.snapshot(webview_id)?;
if snapshot.has_pending_frame() {
host.paint(webview_id)?;
}
let snapshot = host.snapshot(webview_id)?;
let has_rendered_frame =
host.last_rendered_frame().is_ok_and(|frame| frame.non_white_pixel_count() > 0);
if snapshot.state() == &WebViewState::Complete && has_rendered_frame {
return Ok(snapshot);
}
thread::sleep(WAIT_INTERVAL);
}
Err(SidecarError::RenderTimeout {
url: url.to_string(),
snapshot: Box::new(host.snapshot(webview_id)?),
})
}
#[derive(Serialize)]
struct SnapshotReport {
requested_url: String,
loaded_url: Option<String>,
title: Option<String>,
rgba_path: String,
state: &'static str,
width: u32,
height: u32,
rgba_byte_count: usize,
opaque_pixel_count: u64,
non_white_pixel_count: u64,
sample_hash: u64,
}
impl SnapshotReport {
fn new(
requested_url: &str,
rgba_path: &std::path::Path,
snapshot: &WebViewSnapshot,
frame: &RenderedFrame,
) -> Self {
Self {
requested_url: requested_url.to_string(),
loaded_url: snapshot.url().map(str::to_string),
title: snapshot.title().map(str::to_string),
rgba_path: rgba_path.display().to_string(),
state: state_label(snapshot.state()),
width: frame.width(),
height: frame.height(),
rgba_byte_count: frame.rgba_bytes().len(),
opaque_pixel_count: frame.opaque_pixel_count(),
non_white_pixel_count: frame.non_white_pixel_count(),
sample_hash: frame.sample_hash(),
}
}
}
fn state_label(state: &WebViewState) -> &'static str {
match state {
WebViewState::Created => "created",
WebViewState::Loading => "loading",
WebViewState::Complete => "complete",
WebViewState::Sleeping => "sleeping",
WebViewState::Crashed => "crashed",
}
}