Honor CDP screenshot capture options

This commit is contained in:
SGavrl committed 2026-08-03 14:35:13 +00:00
1 parent 1a521220d0
commit cb6fa31d81
3 files changed
+587 -11

No files matched your search

+584 -10
View File
@@ -5,6 +5,274 @@ use crate::dispatch::CdpContext;
use crate::types::CdpEvent;
use crate::util::url_is_file_scheme;
#[cfg(feature = "render")]
const DEFAULT_SCREENSHOT_QUALITY: i64 = 80;
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug, Eq, PartialEq)]
enum ScreenshotFormat {
Png,
Jpeg,
Webp,
}
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug)]
struct ScreenshotClip {
x: f64,
y: f64,
width: f64,
height: f64,
scale: f64,
}
#[cfg(feature = "render")]
#[derive(Clone, Copy, Debug)]
struct ScreenshotOptions {
format: ScreenshotFormat,
quality: u8,
quality_supplied: bool,
clip: Option<ScreenshotClip>,
from_surface: bool,
capture_beyond_viewport: bool,
optimize_for_speed: bool,
}
#[cfg(feature = "render")]
fn screenshot_bool(params: &Value, name: &str, default: bool) -> Result<bool, String> {
match params.get(name) {
None => Ok(default),
Some(Value::Bool(value)) => Ok(*value),
Some(_) => Err(format!("Invalid parameters: {name} must be a boolean")),
}
}
#[cfg(feature = "render")]
fn screenshot_number(object: &serde_json::Map<String, Value>, name: &str) -> Result<f64, String> {
let value = object
.get(name)
.ok_or_else(|| format!("Invalid parameters: mandatory clip.{name} field missing"))?
.as_f64()
.ok_or_else(|| format!("Invalid parameters: clip.{name} must be a number"))?;
if !value.is_finite() {
return Err(format!("Invalid parameters: clip.{name} must be finite"));
}
Ok(value)
}
#[cfg(feature = "render")]
fn parse_screenshot_options(params: &Value) -> Result<ScreenshotOptions, String> {
if !params.is_object() {
return Err("Invalid parameters: expected an object".to_string());
}
let format = match params.get("format") {
None => ScreenshotFormat::Png,
Some(Value::String(value)) => match value.as_str() {
"png" => ScreenshotFormat::Png,
"jpeg" => ScreenshotFormat::Jpeg,
"webp" => ScreenshotFormat::Webp,
_ => return Err("Invalid image format".to_string()),
},
Some(_) => return Err("Invalid parameters: format must be a string".to_string()),
};
let quality_supplied = params.get("quality").is_some();
let quality = match params.get("quality") {
None => DEFAULT_SCREENSHOT_QUALITY,
Some(value) => value
.as_i64()
.filter(|value| i32::try_from(*value).is_ok())
.ok_or_else(|| "Invalid parameters: quality must be an integer".to_string())?,
};
// Chromium accepts an int32 outside [0, 100], but deliberately falls back
// to its default quality instead of rejecting the request.
let quality = if (0..=100).contains(&quality) {
quality
} else {
DEFAULT_SCREENSHOT_QUALITY
} as u8;
let clip = match params.get("clip") {
None => None,
Some(Value::Object(object)) => {
let clip = ScreenshotClip {
x: screenshot_number(object, "x")?,
y: screenshot_number(object, "y")?,
width: screenshot_number(object, "width")?,
height: screenshot_number(object, "height")?,
scale: screenshot_number(object, "scale")?,
};
if clip.width == 0.0 {
return Err("Cannot take screenshot with 0 width.".to_string());
}
if clip.height == 0.0 {
return Err("Cannot take screenshot with 0 height.".to_string());
}
// Chromium's current handler only checks zero, but negative sizes
// and scales enter invalid gfx sizes and may stall the request.
// Fail deterministically instead of risking an allocation or hang.
if clip.width < 0.0 {
return Err("Cannot take screenshot with negative width.".to_string());
}
if clip.height < 0.0 {
return Err("Cannot take screenshot with negative height.".to_string());
}
if clip.scale <= 0.0 {
return Err("Cannot take screenshot with non-positive scale.".to_string());
}
Some(clip)
}
Some(_) => return Err("Invalid parameters: clip must be an object".to_string()),
};
Ok(ScreenshotOptions {
format,
quality,
quality_supplied,
clip,
from_surface: screenshot_bool(params, "fromSurface", true)?,
capture_beyond_viewport: screenshot_bool(params, "captureBeyondViewport", false)?,
optimize_for_speed: screenshot_bool(params, "optimizeForSpeed", false)?,
})
}
#[cfg(feature = "render")]
fn screenshot_output_size(clip: ScreenshotClip) -> Result<(u32, u32), String> {
// Chromium converts the CSS clip size to an integer gfx::Size before
// applying scale to the requested output bitmap.
let width = (clip.width.trunc() * clip.scale).round();
let height = (clip.height.trunc() * clip.scale).round();
const MAX_DIMENSION: f64 = 128.0 * 1024.0;
if width <= 0.0 || height <= 0.0 {
return Err("Screenshot clip is too small at the requested scale.".to_string());
}
if width >= MAX_DIMENSION || height >= MAX_DIMENSION {
return Err("Page is too large.".to_string());
}
let pixels = width * height;
// Four RGBA bytes per pixel keeps this guard below a 256 MiB surface.
if !pixels.is_finite() || pixels > 64.0 * 1024.0 * 1024.0 {
return Err("Screenshot bitmap is too large.".to_string());
}
Ok((width as u32, height as u32))
}
#[cfg(feature = "render")]
fn sample_screenshot_region(
source: &image::RgbaImage,
clip: ScreenshotClip,
relative_x: f64,
relative_y: f64,
) -> Result<image::RgbaImage, String> {
let (output_width, output_height) = screenshot_output_size(clip)?;
let mut output = image::RgbaImage::new(output_width, output_height);
let source_width = source.width() as i64;
let source_height = source.height() as i64;
// Sampling pixel centers gives exact copies for integral, scale=1 clips
// while also handling fractional clip origins and non-integral scales.
for output_y in 0..output_height {
let sample_y =
relative_y + (f64::from(output_y) + 0.5) * clip.height / f64::from(output_height) - 0.5;
let y0 = sample_y.floor() as i64;
let y1 = y0 + 1;
let fy = sample_y - y0 as f64;
for output_x in 0..output_width {
let sample_x = relative_x
+ (f64::from(output_x) + 0.5) * clip.width / f64::from(output_width)
- 0.5;
let x0 = sample_x.floor() as i64;
let x1 = x0 + 1;
let fx = sample_x - x0 as f64;
let pixel = |x: i64, y: i64| {
source.get_pixel(
x.clamp(0, source_width - 1) as u32,
y.clamp(0, source_height - 1) as u32,
)
};
let p00 = pixel(x0, y0);
let p10 = pixel(x1, y0);
let p01 = pixel(x0, y1);
let p11 = pixel(x1, y1);
let mut rgba = [0u8; 4];
for channel in 0..4 {
let top = f64::from(p00[channel]) * (1.0 - fx) + f64::from(p10[channel]) * fx;
let bottom = f64::from(p01[channel]) * (1.0 - fx) + f64::from(p11[channel]) * fx;
rgba[channel] = (top * (1.0 - fy) + bottom * fy).round().clamp(0.0, 255.0) as u8;
}
output.put_pixel(output_x, output_y, image::Rgba(rgba));
}
}
Ok(output)
}
#[cfg(feature = "render")]
fn encode_screenshot(
image: &image::RgbaImage,
options: ScreenshotOptions,
) -> Result<Vec<u8>, String> {
use image::ImageEncoder as _;
let mut output = Vec::new();
match options.format {
ScreenshotFormat::Png => {
let encoder = if options.optimize_for_speed {
image::codecs::png::PngEncoder::new_with_quality(
&mut output,
image::codecs::png::CompressionType::Fast,
image::codecs::png::FilterType::NoFilter,
)
} else {
image::codecs::png::PngEncoder::new(&mut output)
};
encoder
.write_image(
image.as_raw(),
image.width(),
image.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|error| format!("PNG screenshot encoding failed: {error}"))?;
}
ScreenshotFormat::Jpeg => {
let rgb = image::DynamicImage::ImageRgba8(image.clone()).to_rgb8();
// image's pure-Rust JPEG encoder defines its quality range as
// 1..=100; Chromium permits zero, whose effective result is the
// lowest-quality encoding.
let encoder = image::codecs::jpeg::JpegEncoder::new_with_quality(
&mut output,
options.quality.max(1),
);
encoder
.write_image(
rgb.as_raw(),
rgb.width(),
rgb.height(),
image::ExtendedColorType::Rgb8,
)
.map_err(|error| format!("JPEG screenshot encoding failed: {error}"))?;
}
ScreenshotFormat::Webp => {
if options.quality_supplied {
return Err(
"WebP screenshot quality is not supported by the current lossless encoder"
.to_string(),
);
}
image::codecs::webp::WebPEncoder::new_lossless(&mut output)
.write_image(
image.as_raw(),
image.width(),
image.height(),
image::ExtendedColorType::Rgba8,
)
.map_err(|error| format!("WebP screenshot encoding failed: {error}"))?;
}
}
Ok(output)
}
/// Emit the post-navigation event stream into `ctx.pending_events`. Shared
/// by both the in-process `do_navigate` path and the spawned path in
/// `server::process_navigation`, so the recent goto-returns-Response /
@@ -548,18 +816,90 @@ pub async fn handle(
"captureScreenshot" => {
#[cfg(feature = "render")]
{
let page = ctx.get_session_page(session_id).ok_or("No page for session")?;
match page.screenshot(page.viewport) {
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"
let options = parse_screenshot_options(params)?;
if !options.from_surface {
return Err(
"Page.captureScreenshot fromSurface=false is not supported: Obscura has no separate browser-window compositor surface"
.to_string(),
),
);
}
if options.capture_beyond_viewport && options.clip.is_none() {
return Err(
"Page.captureScreenshot full-page captureBeyondViewport=true is not supported without a clip: Obscura cannot yet record an off-viewport surface without relayout or scripted scrolling"
.to_string(),
);
}
if options.format == ScreenshotFormat::Webp && options.quality_supplied {
return Err(
"WebP screenshot quality is not supported by the current lossless encoder"
.to_string(),
);
}
let page = ctx
.get_session_page_mut(session_id)
.ok_or("No page for session")?;
let viewport = page.viewport;
let scroll = if options.clip.is_some() {
page.evaluate("[window.scrollX, window.scrollY]")
.as_array()
.map(|values| {
(
values.first().and_then(Value::as_f64).unwrap_or(0.0),
values.get(1).and_then(Value::as_f64).unwrap_or(0.0),
)
})
.unwrap_or((0.0, 0.0))
} else {
(0.0, 0.0)
};
let png = page.screenshot(viewport).ok_or_else(|| {
"Page.captureScreenshot failed: the page has no DOM to render".to_string()
})?;
// Keep the common path allocation-free and byte-for-byte
// compatible with the renderer's native PNG encoder.
let encoded = if options.format == ScreenshotFormat::Png
&& options.clip.is_none()
&& !options.optimize_for_speed
{
png
} else {
let source = image::load_from_memory_with_format(&png, image::ImageFormat::Png)
.map_err(|error| {
format!("Page.captureScreenshot could not decode renderer PNG: {error}")
})?
.to_rgba8();
let raster = if let Some(clip) = options.clip {
let relative_x = clip.x - scroll.0;
let relative_y = clip.y - scroll.1;
let epsilon = 1e-6;
if relative_x < -epsilon
|| relative_y < -epsilon
|| relative_x + clip.width > f64::from(source.width()) + epsilon
|| relative_y + clip.height > f64::from(source.height()) + epsilon
{
return Err(format!(
"Page.captureScreenshot clip lies outside the current viewport surface; Obscura cannot yet record off-viewport pixels even with captureBeyondViewport=true (visible page rect: x={} y={} width={} height={})",
scroll.0, scroll.1, viewport.0, viewport.1
));
}
sample_screenshot_region(
&source,
clip,
relative_x.max(0.0),
relative_y.max(0.0),
)?
} else {
source
};
encode_screenshot(&raster, options)?
};
use base64::Engine as _;
let data = base64::engine::general_purpose::STANDARD.encode(encoded);
Ok(json!({ "data": data }))
}
#[cfg(not(feature = "render"))]
Err(
@@ -681,6 +1021,240 @@ mod tests {
);
}
#[cfg(feature = "render")]
fn decode_capture(result: &Value) -> (image::ImageFormat, image::RgbaImage) {
use base64::Engine as _;
let bytes = base64::engine::general_purpose::STANDARD
.decode(result["data"].as_str().expect("screenshot data"))
.expect("base64 screenshot");
let format = image::guess_format(&bytes).expect("image format");
let raster = image::load_from_memory(&bytes)
.expect("decodable screenshot")
.to_rgba8();
(format, raster)
}
#[cfg(feature = "render")]
async fn screenshot_fixture() -> (CdpContext, Option<String>) {
let mut ctx = CdpContext::new();
let page_id = ctx.create_page();
let session_id = format!("{page_id}-session");
ctx.sessions.insert(session_id.clone(), page_id);
let session = Some(session_id);
ctx.get_session_page_mut(&session)
.expect("page")
.set_viewport((100.0, 80.0));
handle(
"navigate",
&json!({
"url": "data:text/html,<html style='margin:0'><body style='margin:0;height:160px;background:red'><div style='position:absolute;left:50px;top:0;width:50px;height:80px;background:blue'></div><div style='position:absolute;left:0;top:80px;width:100px;height:80px;background:green'></div></body></html>",
"waitUntil": "load",
}),
&mut ctx,
&session,
)
.await
.expect("navigate screenshot fixture");
(ctx, session)
}
#[cfg(feature = "render")]
#[tokio::test]
async fn capture_screenshot_preserves_default_png_and_honors_clip_scale() {
let (mut ctx, session) = screenshot_fixture().await;
let native = {
let page = ctx.get_session_page(&session).expect("page");
page.screenshot(page.viewport).expect("native png")
};
let default = handle("captureScreenshot", &json!({}), &mut ctx, &session)
.await
.expect("default screenshot");
use base64::Engine as _;
let default_bytes = base64::engine::general_purpose::STANDARD
.decode(default["data"].as_str().expect("default data"))
.expect("default base64");
assert_eq!(
default_bytes, native,
"default CDP path must preserve the renderer PNG bytes exactly"
);
let clipped = handle(
"captureScreenshot",
&json!({
"captureBeyondViewport": true,
"clip": {"x": 50.0, "y": 0.0, "width": 50.0, "height": 40.0, "scale": 2.0}
}),
&mut ctx,
&session,
)
.await
.expect("clipped screenshot");
let (format, raster) = decode_capture(&clipped);
assert_eq!(format, image::ImageFormat::Png);
assert_eq!(raster.dimensions(), (100, 80));
let center = raster.get_pixel(50, 40).0;
assert!(
center[2] > 200 && center[0] < 50,
"clip x/y must select the blue half of the live surface: {center:?}"
);
ctx.get_session_page_mut(&session)
.expect("page")
.evaluate("window.scrollTo(0, 80)");
let page_coordinate_clip = handle(
"captureScreenshot",
&json!({
"clip": {"x": 0.0, "y": 80.0, "width": 20.0, "height": 20.0, "scale": 1.0}
}),
&mut ctx,
&session,
)
.await
.expect("clip in page coordinates after scroll");
let (_, raster) = decode_capture(&page_coordinate_clip);
let center = raster.get_pixel(10, 10).0;
assert!(
center[1] > 80 && center[0] < 50 && center[2] < 50,
"clip coordinates must remain page-relative after scrolling: {center:?}"
);
}
#[cfg(feature = "render")]
#[tokio::test]
async fn capture_screenshot_encodes_jpeg_and_lossless_webp() {
let (mut ctx, session) = screenshot_fixture().await;
let jpeg = handle(
"captureScreenshot",
&json!({"format": "jpeg", "quality": 35}),
&mut ctx,
&session,
)
.await
.expect("jpeg screenshot");
let (format, raster) = decode_capture(&jpeg);
assert_eq!(format, image::ImageFormat::Jpeg);
assert_eq!(raster.dimensions(), (100, 80));
let fast_png = handle(
"captureScreenshot",
&json!({"optimizeForSpeed": true}),
&mut ctx,
&session,
)
.await
.expect("fast PNG screenshot");
let (format, raster) = decode_capture(&fast_png);
assert_eq!(format, image::ImageFormat::Png);
assert_eq!(raster.dimensions(), (100, 80));
let webp = handle(
"captureScreenshot",
&json!({"format": "webp"}),
&mut ctx,
&session,
)
.await
.expect("lossless webp screenshot");
let (format, raster) = decode_capture(&webp);
assert_eq!(format, image::ImageFormat::WebP);
assert_eq!(raster.dimensions(), (100, 80));
let error = handle(
"captureScreenshot",
&json!({"format": "webp", "quality": 35}),
&mut ctx,
&session,
)
.await
.expect_err("lossy WebP must not be silently faked");
assert!(error.contains("lossless encoder"), "{error}");
}
#[cfg(feature = "render")]
#[test]
fn capture_screenshot_validates_like_chromium() {
let options = parse_screenshot_options(&json!({
"format": "jpeg",
"quality": -1,
}))
.expect("out-of-range integer quality falls back");
assert_eq!(options.quality, 80);
let options = parse_screenshot_options(&json!({
"format": "jpeg",
"quality": 101,
}))
.expect("out-of-range integer quality falls back");
assert_eq!(options.quality, 80);
assert_eq!(
parse_screenshot_options(&json!({"format": "gif"})).expect_err("bad format"),
"Invalid image format"
);
assert!(parse_screenshot_options(&json!({"format": 3}))
.expect_err("format type")
.contains("format must be a string"));
assert!(parse_screenshot_options(&json!({"quality": 50.5}))
.expect_err("quality type")
.contains("quality must be an integer"));
assert!(parse_screenshot_options(&json!({"fromSurface": "false"}))
.expect_err("fromSurface type")
.contains("fromSurface must be a boolean"));
assert!(parse_screenshot_options(&json!({"captureBeyondViewport": 1}))
.expect_err("captureBeyondViewport type")
.contains("captureBeyondViewport must be a boolean"));
assert_eq!(
parse_screenshot_options(&json!({
"clip": {"x": 0, "y": 0, "width": 0, "height": 10, "scale": 1}
}))
.expect_err("zero width"),
"Cannot take screenshot with 0 width."
);
assert!(parse_screenshot_options(&json!({
"clip": {"x": 0, "y": 0, "width": 10, "height": 10}
}))
.expect_err("missing scale")
.contains("mandatory clip.scale field missing"));
}
#[cfg(feature = "render")]
#[tokio::test]
async fn capture_screenshot_rejects_unrepresented_surfaces() {
let (mut ctx, session) = screenshot_fixture().await;
let beyond = handle(
"captureScreenshot",
&json!({"captureBeyondViewport": true}),
&mut ctx,
&session,
)
.await
.expect_err("full-page recording is unsupported");
assert!(beyond.contains("captureBeyondViewport=true"), "{beyond}");
let off_surface = handle(
"captureScreenshot",
&json!({"fromSurface": false}),
&mut ctx,
&session,
)
.await
.expect_err("browser-window compositor is unsupported");
assert!(off_surface.contains("fromSurface=false"), "{off_surface}");
let off_viewport = handle(
"captureScreenshot",
&json!({
"clip": {"x": 90, "y": 0, "width": 20, "height": 20, "scale": 1}
}),
&mut ctx,
&session,
)
.await
.expect_err("partial off-viewport clip is unsupported");
assert!(
off_viewport.contains("outside the current viewport surface"),
"{off_viewport}"
);
}
#[tokio::test]
async fn unknown_page_method_still_errors() {
let mut ctx = CdpContext::new();