From cdd52ade4348154814277942899def8988641fcb Mon Sep 17 00:00:00 2001 From: SGavrl <277843151+SGavrl@users.noreply.github.com> Date: Wed, 1 Jul 2026 21:34:15 +0000 Subject: [PATCH] render: PNG screenshots via CLI and CDP End-to-end rasterization behind the render feature (which now enables obscura-render/paint): - obscura-render: screenshot_png(dom, viewport) paints and encodes to PNG via tiny-skia's encode_png (no extra dep). - obscura-browser: Page::screenshot renders the live DOM at a viewport. - CLI: obscura fetch --screenshot out.png writes the settled page to a PNG. - CDP: Page.captureScreenshot returns the PNG base64-encoded, so Puppeteer/Playwright page.screenshot() works. captureSnapshot still errors (it is a DOM/layer snapshot, not an image). Verified: both paths produce correct 1280x720 RGBA PNGs with elements laid out and colored as the CSS specifies (block stacking, backgrounds, white page). Default build unchanged; obstacle course 33/33. Text, borders, and images are not yet painted; the box layout and background colors are rendered. --- crates/obscura-browser/src/page.rs | 8 ++++++ crates/obscura-cdp/Cargo.toml | 7 +++++ crates/obscura-cdp/src/domains/page.rs | 30 ++++++++++++++++---- crates/obscura-cli/Cargo.toml | 2 +- crates/obscura-cli/src/main.rs | 38 ++++++++++++++++++++++++-- crates/obscura-js/Cargo.toml | 6 ++-- crates/obscura-js/src/lib.rs | 5 ++++ crates/obscura-render/src/lib.rs | 2 +- crates/obscura-render/src/paint.rs | 6 ++++ 9 files changed, 92 insertions(+), 12 deletions(-) diff --git a/crates/obscura-browser/src/page.rs b/crates/obscura-browser/src/page.rs index 2e169fd..f4df49a 100644 --- a/crates/obscura-browser/src/page.rs +++ b/crates/obscura-browser/src/page.rs @@ -1281,6 +1281,14 @@ impl Page { self.dom.as_ref().map(f) } + /// Rasterize the current DOM to PNG bytes at `viewport` (CSS pixels), when + /// the render feature is compiled in. None if the page has no DOM or the + /// viewport is zero-sized. + #[cfg(feature = "render")] + pub fn screenshot(&self, viewport: (f32, f32)) -> Option> { + self.with_dom(|dom| obscura_js::screenshot_png(dom, viewport)).flatten() + } + /// Absolute URLs the page pulled in via fetch()/XHR (issue #301). Empty /// when the page has no live JS runtime. pub fn fetched_urls(&self) -> Vec { diff --git a/crates/obscura-cdp/Cargo.toml b/crates/obscura-cdp/Cargo.toml index 38e3551..07fc627 100644 --- a/crates/obscura-cdp/Cargo.toml +++ b/crates/obscura-cdp/Cargo.toml @@ -3,6 +3,12 @@ name = "obscura-cdp" version.workspace = true edition.workspace = true +[features] +default = [] +# Enable Page.captureScreenshot via obscura-render. Propagates render to +# obscura-browser so Page::screenshot is available. +render = ["obscura-browser/render"] + [dependencies] obscura-dom = { workspace = true } obscura-browser = { workspace = true } @@ -16,6 +22,7 @@ uuid = { workspace = true } tracing = { workspace = true } thiserror = { workspace = true } anyhow = { workspace = true } +base64 = { workspace = true } futures-util = "0.3" base64 = "0.22" obscura-net = { workspace = true } diff --git a/crates/obscura-cdp/src/domains/page.rs b/crates/obscura-cdp/src/domains/page.rs index 218e341..d92320f 100644 --- a/crates/obscura-cdp/src/domains/page.rs +++ b/crates/obscura-cdp/src/domains/page.rs @@ -524,11 +524,31 @@ pub async fn handle( .to_string(), ) } - "captureScreenshot" | "captureSnapshot" => { - // Same story as printToPDF: rasterising a page needs a layout and - // paint pipeline that Obscura intentionally does not have. Reply - // with a clear error so clients can fail fast instead of waiting - // on the generic "Unknown Page method" reply. + "captureScreenshot" => { + #[cfg(feature = "render")] + { + let page = ctx.get_session_page(session_id).ok_or("No page for session")?; + match page.screenshot((1280.0, 720.0)) { + Some(png) => { + use base64::Engine as _; + let data = base64::engine::general_purpose::STANDARD.encode(&png); + Ok(json!({ "data": data })) + } + None => Err( + "Page.captureScreenshot failed: the page has no DOM to render" + .to_string(), + ), + } + } + #[cfg(not(feature = "render"))] + Err( + "Page.captureScreenshot requires a build with the render feature" + .to_string(), + ) + } + "captureSnapshot" => { + // A DOM/layer-tree snapshot (not a raster image). Distinct from + // captureScreenshot; keep the clear error so clients fail fast. Err(format!( "Page.{method} is not supported by Obscura: no layout or paint engine. \ For visual snapshots, drive a real headless Chromium for the \ diff --git a/crates/obscura-cli/Cargo.toml b/crates/obscura-cli/Cargo.toml index fcb06b0..2d24cc8 100644 --- a/crates/obscura-cli/Cargo.toml +++ b/crates/obscura-cli/Cargo.toml @@ -14,7 +14,7 @@ path = "src/worker.rs" [features] default = [] stealth = ["obscura-browser/stealth", "obscura-net/stealth", "obscura-mcp/stealth"] -render = ["obscura-browser/render"] +render = ["obscura-browser/render", "obscura-cdp/render"] [dependencies] obscura-browser = { workspace = true } diff --git a/crates/obscura-cli/src/main.rs b/crates/obscura-cli/src/main.rs index 5b8d63f..f766045 100644 --- a/crates/obscura-cli/src/main.rs +++ b/crates/obscura-cli/src/main.rs @@ -152,6 +152,11 @@ enum Command { #[arg(long)] storage_dir: Option, + + /// Render the page to a PNG screenshot at this path. Requires a build + /// with the `render` feature. + #[arg(long)] + screenshot: Option, }, Scrape { @@ -370,11 +375,14 @@ async fn main() -> anyhow::Result<()> { ).await?; } } - Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, eval, output, quiet, storage_dir, file, concurrency }) => { + Some(Command::Fetch { url, dump, selector, wait, timeout, wait_until, user_agent, eval, output, quiet, storage_dir, file, concurrency, screenshot }) => { if let Some(file) = file { if url.is_some() { anyhow::bail!("Pass URLs via a positional argument or --file, not both."); } + if screenshot.is_some() { + anyhow::bail!("--screenshot is only supported for a single URL, not --file batch mode."); + } // Batch mode is raw HTTP only. Rendering each URL through the // browser/JS stack is what `scrape` is for. match dump { @@ -389,7 +397,7 @@ async fn main() -> anyhow::Result<()> { let url = url.ok_or_else(|| { anyhow::anyhow!("No URL provided. Pass a URL, or a list of URLs with --file .") })?; - run_fetch(&url, dump, selector, wait, timeout, &wait_until, user_agent, stealth, eval, output, quiet, global_proxy, storage_dir, args.allow_private_network).await?; + run_fetch(&url, dump, selector, wait, timeout, &wait_until, user_agent, stealth, eval, output, quiet, global_proxy, storage_dir, args.allow_private_network, screenshot).await?; } } Some(Command::Scrape { urls, eval, concurrency, format, timeout, quiet }) => { @@ -566,6 +574,7 @@ async fn run_fetch( proxy: Option, storage_dir: Option, allow_private_network: bool, + screenshot: Option, ) -> anyhow::Result<()> { // Whether the user explicitly passed --dump. With --eval also present this // decides whether we return the eval value or read the page after the @@ -644,6 +653,31 @@ async fn run_fetch( // pages stay fast. page.settle(wait_secs.saturating_mul(1000)).await; + // --screenshot renders the settled page to a PNG. Requires the render + // feature; without it, page.screenshot is absent and we report clearly. + if let Some(ref path) = screenshot { + #[cfg(feature = "render")] + { + // Default CSS-pixel viewport, matching the engine's innerWidth/Height. + match page.screenshot((1280.0, 720.0)) { + Some(bytes) => std::fs::write(path, &bytes)?, + None => anyhow::bail!("screenshot failed: page has no DOM to render"), + } + if !quiet { + eprintln!("Screenshot written: {} ({} bytes)", path.display(), std::fs::metadata(path).map(|m| m.len()).unwrap_or(0)); + } + context.save_cookies(); + return Ok(()); + } + #[cfg(not(feature = "render"))] + { + anyhow::bail!( + "--screenshot {} requires a build with the render feature (cargo build --features render)", + path.display() + ); + } + } + if let Some(ref expr) = eval { // Bound the eval by the same budget as navigation so a runaway // expression (infinite loop, never-settling sync work) cannot hang. diff --git a/crates/obscura-js/Cargo.toml b/crates/obscura-js/Cargo.toml index b0d2b7b..61f4e8b 100644 --- a/crates/obscura-js/Cargo.toml +++ b/crates/obscura-js/Cargo.toml @@ -10,9 +10,9 @@ default = [] # TLS fingerprint and client hints as stealth navigation. stealth = ["obscura-net/stealth"] # Builds the obscura-render layout layer and exposes real box geometry to JS -# (getBoundingClientRect, elementFromPoint, offset*). Off by default so the -# scraping build stays lean; the cache is feature-gated and absent otherwise. -render = ["dep:obscura-render"] +# (getBoundingClientRect, elementFromPoint, offset*), and with paint enabled, +# rasterizes screenshots. Off by default so the scraping build stays lean. +render = ["dep:obscura-render", "obscura-render/paint"] [build-dependencies] deno_core = "0.350" diff --git a/crates/obscura-js/src/lib.rs b/crates/obscura-js/src/lib.rs index c9919f0..7f6e2b3 100644 --- a/crates/obscura-js/src/lib.rs +++ b/crates/obscura-js/src/lib.rs @@ -10,3 +10,8 @@ pub mod markdown; pub use markdown::HTML_TO_MARKDOWN_JS; pub use v8_flags::set_v8_flags; + +// Screenshot rasterization (PNG bytes) from the render layer. Available when the +// render feature (which enables obscura-render/paint) is compiled in. +#[cfg(feature = "render")] +pub use obscura_render::screenshot_png; diff --git a/crates/obscura-render/src/lib.rs b/crates/obscura-render/src/lib.rs index 366ff5d..b59ed92 100644 --- a/crates/obscura-render/src/lib.rs +++ b/crates/obscura-render/src/lib.rs @@ -22,7 +22,7 @@ pub use dom::{layout_dom, DomLayout}; #[cfg(feature = "paint")] mod paint; #[cfg(feature = "paint")] -pub use paint::paint_dom; +pub use paint::{paint_dom, screenshot_png}; /// An axis-aligned rectangle in CSS pixels, relative to the containing block. #[derive(Debug, Clone, Copy, Default, PartialEq)] diff --git a/crates/obscura-render/src/paint.rs b/crates/obscura-render/src/paint.rs index 7a2d763..f566df8 100644 --- a/crates/obscura-render/src/paint.rs +++ b/crates/obscura-render/src/paint.rs @@ -53,6 +53,12 @@ fn bg_color_for(tree: &DomTree, nid: obscura_dom::tree::NodeId) -> Option<[u8; 4 style.background_color } +/// Render `tree` at `viewport` to PNG bytes (RGBA 8-bit). Returns None if the +/// viewport is zero-sized. Convenience over `paint_dom` + `encode_png`. +pub fn screenshot_png(tree: &DomTree, viewport: (f32, f32)) -> Option> { + paint_dom(tree, viewport)?.encode_png().ok() +} + #[cfg(test)] mod tests { use super::*;