use std::cell::RefCell; use std::hash::Hasher; use std::sync::Arc; use ahash::AHasher; #[cfg(target_os = "macos")] use core_video::pixel_buffer::CVPixelBuffer; use gpui::RenderImage; use image::{ImageBuffer, Rgba}; use thiserror::Error; use crate::services::servo_live::ServoLiveFrame; thread_local! { /// Single-slot cache of the most-recently-uploaded RGBA payload. /// At 60 fps on a 1080p canvas the previous `from_parts` was /// unconditionally building a fresh `Arc` for every /// tick — even when the bytes were bit-identical to the last /// frame. The cache keys on a 64-bit hash of the raw bytes and /// reuses the existing `Arc` whenever the hash /// matches, so steady-state idle pages no longer churn the GPUI /// texture pool. /// /// Uses `AHasher` instead of std's `DefaultHasher`. SipHash13 /// (default) tops out around ~1.5 GB/s; an 8 MB 1080p frame /// hashes in ~5 ms on a modern CPU, which eats roughly 30 % of /// the 16 ms scroll budget on every cache-miss frame. AHash /// runs ~10 GB/s on the same hardware, dropping the per-frame /// hash cost to ~0.8 ms and giving the scroll path back most of /// that budget. Hash collisions remain ~1 in 2^64; if they ever /// matter we'll trade in length + first/last 32 bytes as a /// disambiguator before paying the full memcmp. static LAST_FRAME_IMAGE: RefCell)>> = const { RefCell::new(None) }; } #[cfg(all(test, feature = "live-site-smoke"))] use super::web_surface_geometry::WebSurfaceSize; use super::web_surface_geometry::{WebSurfaceClickPoint, WebSurfaceScrollOffset}; #[derive(Clone)] #[cfg_attr(not(all(test, feature = "live-site-smoke")), allow(dead_code))] pub(super) struct WebSurfaceFrame { pub(super) requested_url: String, loaded_url: Option, title: Option, render_state: String, width: u32, height: u32, scroll_offset: WebSurfaceScrollOffset, zoom_percent: u16, click_point: Option, typed_text: Option, #[cfg(all(test, feature = "live-site-smoke"))] non_white_pixel_count: u64, #[cfg(all(test, feature = "live-site-smoke"))] content_pixel_count: u64, #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: u64, /// Software-path image. Current GPUI builds require this for every /// ready web frame because BGRA IOSurface presentation is still /// held at the protocol boundary. pub(super) image: Option>, /// Hardware-path companion imported from the sidecar. GPUI 0.2.2's /// public `surface(...)` presenter accepts NV12 video buffers, and /// Servo publishes BGRA IOSurfaces; this remains observability /// state until a BGRA presenter is available. #[cfg(target_os = "macos")] pub(super) pixel_buffer: Option, } impl WebSurfaceFrame { pub(super) fn from_live_frame( requested_url: String, scroll_offset: WebSurfaceScrollOffset, zoom_percent: u16, frame: ServoLiveFrame, ) -> Result { #[cfg(target_os = "macos")] let pixel_buffer = frame.pixel_buffer().cloned(); Self::from_parts(WebSurfaceFrameParts { requested_url, loaded_url: frame.loaded_url().map(str::to_string), title: frame.title().map(str::to_string), render_state: frame.render_state().to_string(), width: frame.width(), height: frame.height(), scroll_offset, zoom_percent, click_point: None, typed_text: None, #[cfg(all(test, feature = "live-site-smoke"))] non_white_pixel_count: frame.non_white_pixel_count(), #[cfg(all(test, feature = "live-site-smoke"))] content_pixel_count: frame.content_pixel_count(), #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: frame.sample_hash(), rgba_bytes: frame.into_rgba_bytes(), #[cfg(target_os = "macos")] pixel_buffer, }) } fn from_parts(parts: WebSurfaceFrameParts) -> Result { if parts.rgba_bytes.is_empty() { return Err(WebSurfaceError::MissingRenderablePayload); } // Servo's `read_pixels(gl::RGBA, gl::UNSIGNED_BYTE)` writes // R-G-B-A in memory order. GPUI's `RenderImage` is documented // as "in BGRA format" and uploads via // `MTLPixelFormat::BGRA8Unorm`, which reads B-G-R-A. Hand the bytes across // unchanged and the Metal sampler treats R as B (and vice // versa) — every coloured pixel renders with R and B swapped. // Swap once here so the rest of the pipeline (dedup hash, // image buffer, GPU upload) all operate on the same BGRA // representation. let mut bytes = parts.rgba_bytes; swap_red_blue_in_place(&mut bytes); let bytes_hash = rgba_hash(&bytes); let image = Some(resolve_render_image(parts.width, parts.height, bytes, bytes_hash)?); Ok(Self { requested_url: parts.requested_url, loaded_url: parts.loaded_url, title: parts.title, render_state: parts.render_state, width: parts.width, height: parts.height, scroll_offset: parts.scroll_offset, zoom_percent: parts.zoom_percent, click_point: parts.click_point, typed_text: parts.typed_text, #[cfg(all(test, feature = "live-site-smoke"))] non_white_pixel_count: parts.non_white_pixel_count, #[cfg(all(test, feature = "live-site-smoke"))] content_pixel_count: parts.content_pixel_count, #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: parts.sample_hash, image, #[cfg(target_os = "macos")] pixel_buffer: parts.pixel_buffer, }) } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn title_label(&self) -> String { self.title.clone().unwrap_or_else(|| self.requested_url.clone()) } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn url_label(&self) -> &str { self.loaded_url.as_deref().unwrap_or(self.requested_url.as_str()) } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn detail_label(&self) -> String { let mut detail = format!("{} {}", self.render_state(), self.scroll_offset.detail_label(self.size())); if self.zoom_percent != ely_domain::DEFAULT_ZOOM_PERCENT { detail = format!("{detail} zoom={}%", self.zoom_percent); } if let Some(click_point) = self.click_point { detail = format!("{detail} {}", click_point.detail_label()); } match self.typed_text.as_ref() { Some(typed_text) => format!("{detail} text={}b", typed_text.len()), None => detail, } } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn size(&self) -> WebSurfaceSize { WebSurfaceSize { width: self.width, height: self.height } } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn render_state(&self) -> &str { self.render_state.as_str() } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn scroll_offset(&self) -> WebSurfaceScrollOffset { self.scroll_offset } pub(super) fn zoom_percent(&self) -> u16 { self.zoom_percent } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn click_point(&self) -> Option { self.click_point } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn typed_text(&self) -> Option<&str> { self.typed_text.as_deref() } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn non_white_pixel_count(&self) -> u64 { self.non_white_pixel_count } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn content_pixel_count(&self) -> u64 { self.content_pixel_count } #[cfg(all(test, feature = "live-site-smoke"))] pub(super) fn sample_hash(&self) -> u64 { self.sample_hash } } struct WebSurfaceFrameParts { requested_url: String, loaded_url: Option, title: Option, render_state: String, width: u32, height: u32, scroll_offset: WebSurfaceScrollOffset, zoom_percent: u16, click_point: Option, typed_text: Option, #[cfg(all(test, feature = "live-site-smoke"))] non_white_pixel_count: u64, #[cfg(all(test, feature = "live-site-smoke"))] content_pixel_count: u64, #[cfg(all(test, feature = "live-site-smoke"))] sample_hash: u64, rgba_bytes: Vec, #[cfg(target_os = "macos")] pixel_buffer: Option, } #[derive(Debug, Error)] pub(super) enum WebSurfaceError { #[error("invalid servo frame buffer for {width}x{height}")] InvalidFrameBuffer { width: u32, height: u32 }, #[error( "servo live frame did not include renderable pixels; BGRA IOSurface presentation is unavailable in GPUI 0.2.2" )] MissingRenderablePayload, } /// Swap byte 0 and byte 2 of every 4-byte pixel, converting Servo's /// RGBA8 output into GPUI's expected BGRA8 in place. ~0.8 ms on a /// 1080p frame; cheap relative to the AHash pass that follows. fn swap_red_blue_in_place(bytes: &mut [u8]) { for pixel in bytes.chunks_exact_mut(4) { pixel.swap(0, 2); } } fn rgba_hash(bytes: &[u8]) -> u64 { let mut hasher = AHasher::default(); hasher.write(bytes); hasher.finish() } fn resolve_render_image( width: u32, height: u32, rgba_bytes: Vec, bytes_hash: u64, ) -> Result, WebSurfaceError> { LAST_FRAME_IMAGE.with(|cache| -> Result, WebSurfaceError> { let mut cache = cache.borrow_mut(); if let Some((cached_hash, cached_image)) = cache.as_ref() { if *cached_hash == bytes_hash { return Ok(cached_image.clone()); } } let image_buffer = ImageBuffer::, _>::from_raw(width, height, rgba_bytes) .ok_or(WebSurfaceError::InvalidFrameBuffer { width, height })?; let new_image = Arc::new(RenderImage::new([image::Frame::new(image_buffer)])); *cache = Some((bytes_hash, new_image.clone())); Ok(new_image) }) }