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:
1 parent
4ca85e87d8
commit
cdd52ade43
9 files changed
+92
-12
No files matched your search
@@ -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> {
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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 \
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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;
|
||||
@@ -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)]
|
||||
|
||||
@@ -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::*;
|
||||
|
||||
Reference in new issue
Block a user