glass-browser 0.2.4

Local, revision-safe Chrome automation runtime for agents, with semantic observation, verified workflows, MCP, CLI, TUI, and Rust APIs
Documentation
use base64::{Engine, engine::general_purpose::STANDARD};
use glass::browser::chrome::detect_chrome;
use glass::browser::session::{
    BrowserResult, BrowserSession, InteractionMode, SessionOptions, VisualFormat,
};
use serde_json::{Value, json};
use std::collections::{HashSet, hash_map::DefaultHasher};
use std::hash::{Hash, Hasher};
use std::time::{Duration, Instant};
use tokio::net::TcpListener;
use tokio::task::JoinSet;

const DEFAULT_FRAMES: usize = 120;
const DEFAULT_WARMUP_FRAMES: usize = 10;
const VIEWPORT_WIDTH: u32 = 1_280;
const VIEWPORT_HEIGHT: u32 = 720;
const ANIMATED_FIXTURE: &str = r#"<!doctype html>
<html lang="en">
  <head>
    <meta charset="utf-8">
    <title>Glass Capture Loop</title>
    <style>
      html, body { width: 100%; height: 100%; margin: 0; overflow: hidden; }
      body { background: linear-gradient(135deg, #07152d, #23658d); color: white; }
      #card {
        position: absolute; width: 260px; height: 160px; border-radius: 28px;
        display: grid; place-items: center; font: 700 28px system-ui;
        background: linear-gradient(135deg, #ffb703, #fb5607);
        box-shadow: 0 24px 80px #0008; will-change: transform;
      }
      #counter { position: absolute; left: 32px; top: 24px; font: 24px ui-monospace; }
    </style>
  </head>
  <body>
    <div id="counter">0</div><div id="card">Glass</div>
    <script>
      const card = document.querySelector('#card');
      const counter = document.querySelector('#counter');
      let frame = 0;
      function animate(time) {
        const x = 500 + Math.sin(time / 430) * 420;
        const y = 280 + Math.cos(time / 610) * 210;
        card.style.transform = `translate(${x}px, ${y}px) rotate(${time / 30}deg)`;
        counter.textContent = String(++frame);
        requestAnimationFrame(animate);
      }
      requestAnimationFrame(animate);
    </script>
  </body>
</html>"#;

#[derive(Debug)]
struct DecodedFrame {
    index: usize,
    bytes: usize,
    width: usize,
    height: usize,
    hash: u64,
}

#[tokio::main]
async fn main() -> BrowserResult<()> {
    let frames = positive_env("GLASS_SCREENCAST_FRAMES", DEFAULT_FRAMES);
    let warmup = positive_env("GLASS_SCREENCAST_WARMUP", DEFAULT_WARMUP_FRAMES);
    let format = std::env::var("GLASS_SCREENCAST_FORMAT").unwrap_or_else(|_| "jpeg".to_string());
    if !matches!(format.as_str(), "jpeg" | "png") {
        return Err("GLASS_SCREENCAST_FORMAT must be jpeg or png".into());
    }
    let quality = std::env::var("GLASS_SCREENCAST_QUALITY")
        .ok()
        .and_then(|value| value.parse::<u32>().ok())
        .filter(|quality| *quality <= 100)
        .unwrap_or(80);

    let Some(chrome_path) = detect_chrome() else {
        return Err("Chrome/Chromium is required for the screencast benchmark".into());
    };
    let listener = TcpListener::bind("127.0.0.1:0").await?;
    let port = listener.local_addr()?.port();
    drop(listener);
    let session = BrowserSession::start(&SessionOptions {
        port,
        chrome_path: Some(chrome_path),
        profile: "screencast-benchmark".to_string(),
        incognito: true,
        attach: false,
        target_id: None,
        frame_id: None,
        headed: false,
        interaction_mode: InteractionMode::Fast,
        audit: false,
        policy: None,
    })
    .await?;
    session
        .raw_cdp()?
        .send(
            "Emulation.setDeviceMetricsOverride",
            Some(json!({
                "width": VIEWPORT_WIDTH,
                "height": VIEWPORT_HEIGHT,
                "deviceScaleFactor": 1,
                "mobile": false
            })),
        )
        .await?;
    let url = format!(
        "data:text/html;base64,{}",
        STANDARD.encode(ANIMATED_FIXTURE)
    );
    session.navigate(&url).await?;

    let polling = benchmark_polling(&session, &format, quality, frames, warmup).await?;
    let screencast = benchmark_screencast(&session, &format, quality, frames, warmup).await?;
    println!(
        "{}",
        serde_json::to_string_pretty(&json!({
            "tool": "glass-screencast-poc",
            "semantics": "deterministic animated viewport; compressed images; no file I/O",
            "viewport": {
                "width": VIEWPORT_WIDTH,
                "height": VIEWPORT_HEIGHT,
                "deviceScaleFactor": 1
            },
            "format": format,
            "quality": quality,
            "frames": frames,
            "warmup_frames": warmup,
            "polling_capture_screenshot": polling,
            "cdp_start_screencast": screencast
        }))?
    );
    session.close().await
}

async fn benchmark_polling(
    session: &BrowserSession,
    format: &str,
    quality: u32,
    frames: usize,
    warmup: usize,
) -> BrowserResult<Value> {
    let params = capture_params(format, quality);
    for _ in 0..warmup {
        let _ = capture_and_decode(session, params.clone()).await?;
    }
    let started = Instant::now();
    let mut decoded = Vec::with_capacity(frames);
    for index in 0..frames {
        let mut frame = capture_and_decode(session, params.clone()).await?;
        frame.index = index;
        decoded.push(frame);
    }
    let elapsed = started.elapsed();
    summarize_frames(
        decoded,
        elapsed,
        json!({
            "commands": frames,
            "responses": frames,
            "optimizeForSpeed": true
        }),
    )
}

async fn benchmark_screencast(
    session: &BrowserSession,
    format: &str,
    quality: u32,
    frames: usize,
    warmup: usize,
) -> BrowserResult<Value> {
    let visual_format = match format {
        "jpeg" => VisualFormat::Jpeg,
        "png" => VisualFormat::Png,
        _ => return Err("unsupported screencast format".into()),
    };
    let started_command = Instant::now();
    let mut screencast = session
        .start_screencast(
            visual_format,
            quality as u8,
            VIEWPORT_WIDTH,
            VIEWPORT_HEIGHT,
        )
        .await?;
    let start_command = started_command.elapsed();

    for _ in 0..warmup {
        screencast
            .next_frame()
            .await
            .ok_or("screencast ended during warmup")?;
    }

    let wire_started = Instant::now();
    let mut first_arrival = None;
    let mut last_arrival = None;
    let mut decoders = JoinSet::new();
    let mut received = 0_usize;
    while received < frames {
        let frame = screencast
            .next_frame()
            .await
            .ok_or("screencast ended before the requested frame count")?;
        let arrival = Instant::now();
        first_arrival.get_or_insert(arrival);
        last_arrival = Some(arrival);
        let index = received;
        decoders.spawn_blocking(move || decode_frame(index, frame.data));
        received += 1;
    }
    let wire_elapsed = wire_started.elapsed();
    let stream_stats = screencast.stop().await?;
    let mut decoded = Vec::with_capacity(frames);
    while let Some(result) = decoders.join_next().await {
        decoded.push(result.map_err(|error| error.to_string())??);
    }
    decoded.sort_by_key(|frame| frame.index);
    let decoded_elapsed = wire_started.elapsed();
    let arrival_span = match (first_arrival, last_arrival) {
        (Some(first), Some(last)) => last.duration_since(first),
        _ => Duration::ZERO,
    };
    let details = json!({
        "start_command_ms": duration_ms(start_command),
        "wire_receive_ms": duration_ms(wire_elapsed),
        "wire_frames_per_second": frames as f64 / wire_elapsed.as_secs_f64(),
        "arrival_span_frames_per_second": if frames > 1 && !arrival_span.is_zero() {
            Some((frames - 1) as f64 / arrival_span.as_secs_f64())
        } else {
            None
        },
        "receive_plus_decode_ms": duration_ms(decoded_elapsed),
        "receive_plus_decode_fps": frames as f64 / decoded_elapsed.as_secs_f64(),
        "received_frames": stream_stats.received,
        "dropped_frames": stream_stats.dropped,
        "commands": frames + warmup + 2,
        "frame_events": frames + warmup,
        "note": "Glass ACKs frames before bounded channel delivery; decode uses Tokio's blocking pool"
    });
    summarize_frames(decoded, decoded_elapsed, details)
}

async fn capture_and_decode(
    session: &BrowserSession,
    params: Value,
) -> BrowserResult<DecodedFrame> {
    let response = session
        .raw_cdp()?
        .send("Page.captureScreenshot", Some(params))
        .await?;
    let data = response["data"]
        .as_str()
        .ok_or("capture response contained no data")?;
    decode_frame(0, data.to_string()).map_err(Into::into)
}

fn decode_frame(index: usize, data: String) -> Result<DecodedFrame, String> {
    let image = STANDARD
        .decode(data.as_bytes())
        .map_err(|error| error.to_string())?;
    let size = imagesize::blob_size(&image).map_err(|error| error.to_string())?;
    let mut hasher = DefaultHasher::new();
    image.hash(&mut hasher);
    Ok(DecodedFrame {
        index,
        bytes: image.len(),
        width: size.width,
        height: size.height,
        hash: hasher.finish(),
    })
}

fn summarize_frames(
    frames: Vec<DecodedFrame>,
    elapsed: Duration,
    details: Value,
) -> BrowserResult<Value> {
    if frames.is_empty() {
        return Err("capture produced no frames".into());
    }
    if frames.iter().any(|frame| {
        frame.width != VIEWPORT_WIDTH as usize || frame.height != VIEWPORT_HEIGHT as usize
    }) {
        return Err("capture produced an unexpected image size".into());
    }
    let distinct: HashSet<_> = frames.iter().map(|frame| frame.hash).collect();
    let average_bytes = frames.iter().map(|frame| frame.bytes).sum::<usize>() / frames.len();
    Ok(json!({
        "elapsed_ms": duration_ms(elapsed),
        "frames_per_second": frames.len() as f64 / elapsed.as_secs_f64(),
        "average_image_bytes": average_bytes,
        "distinct_frame_hashes": distinct.len(),
        "width": VIEWPORT_WIDTH,
        "height": VIEWPORT_HEIGHT,
        "details": details
    }))
}

fn capture_params(format: &str, quality: u32) -> Value {
    if format == "jpeg" {
        json!({"format": format, "quality": quality, "optimizeForSpeed": true})
    } else {
        json!({"format": format, "optimizeForSpeed": true})
    }
}

fn duration_ms(duration: Duration) -> f64 {
    duration.as_secs_f64() * 1_000.0
}

fn positive_env(name: &str, fallback: usize) -> usize {
    std::env::var(name)
        .ok()
        .and_then(|value| value.parse().ok())
        .filter(|value| *value > 0)
        .unwrap_or(fallback)
}