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.
This commit is contained in:
SGavrl committed 2026-07-01 21:34:15 +00:00
1 parent 4ca85e87d8
commit cdd52ade43
9 files changed
+92 -12

No files matched your search

+8
View File
@@ -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<Vec<u8>> {
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<String> {
+7
View File
@@ -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 }
+25 -5
View File
@@ -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 \
+1 -1
View File
@@ -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 }
+36 -2
View File
@@ -152,6 +152,11 @@ enum Command {
#[arg(long)]
storage_dir: Option<std::path::PathBuf>,
/// Render the page to a PNG screenshot at this path. Requires a build
/// with the `render` feature.
#[arg(long)]
screenshot: Option<std::path::PathBuf>,
},
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 <path>.")
})?;
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<String>,
storage_dir: Option<std::path::PathBuf>,
allow_private_network: bool,
screenshot: Option<std::path::PathBuf>,
) -> 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.
+3 -3
View File
@@ -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"
+5
View File
@@ -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;
+1 -1
View File
@@ -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)]
+6
View File
@@ -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<Vec<u8>> {
paint_dom(tree, viewport)?.encode_png().ok()
}
#[cfg(test)]
mod tests {
use super::*;