fix: use custom viewport dimensions in streaming frame metadata and image resolution (#1033)
* fix: use custom viewport dimensions in streaming frame metadata CDP's Page.screencastFrame metadata returns physical device dimensions instead of the emulated viewport, causing frame messages to report incorrect deviceWidth/deviceHeight when a custom viewport is set. Use the viewport dimensions captured at screencast start instead of the CDP metadata values, since the screencast image is already captured at the configured viewport size. Closes #1031 * fix: resize browser content area on viewport change for correct screencast dimensions Emulation.setDeviceMetricsOverride only changes the CSS viewport, but screencast captures the actual browser content area. This caused frame images to have incorrect dimensions (e.g., 1000x451 instead of 1000x1000) when a custom viewport was set. - Call Browser.setContentsSize after setDeviceMetricsOverride so the content area matches the emulated viewport - Restart active screencast when viewport dimensions change so maxWidth/maxHeight parameters are updated - Skip redundant screencast restarts when dimensions are unchanged - Extend E2E test to verify actual JPEG image dimensions, not just metadata * fix: pass viewport dimensions to --window-size at launch and log setContentsSize failures - Add viewport_size to LaunchOptions so --window-size matches the configured viewport from the start, reducing reliance on the experimental Browser.setContentsSize CDP call at runtime - Log Browser.setContentsSize failures instead of silently ignoring them with let _ = * fix: remove duplicate viewport change detection block (dead code from merge) * fix: use log::debug! instead of eprintln! for setContentsSize failure * revert: use eprintln! instead of log crate for setContentsSize failure The daemon's stderr pipe is closed after startup, so log crate subscribers cannot output during normal operation. eprintln! is visible during startup and in tests, matching the existing convention. --------- Co-authored-by: hyunjinee <leehj0110@kakao.com> Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
hyunjinee
Claude Opus 4.6
parent
6520e4123c
commit
2164e71c30
@@ -1491,7 +1491,13 @@ async fn connect_auto_with_fresh_tab() -> Result<BrowserManager, String> {
|
||||
}
|
||||
|
||||
async fn auto_launch(state: &mut DaemonState) -> Result<(), String> {
|
||||
let options = launch_options_from_env();
|
||||
let mut options = launch_options_from_env();
|
||||
|
||||
// Use the stream server's viewport dimensions for --window-size so the
|
||||
// content area matches the desired viewport from the start.
|
||||
if let Some(ref server) = state.stream_server {
|
||||
options.viewport_size = Some(server.viewport().await);
|
||||
}
|
||||
let engine = env::var("AGENT_BROWSER_ENGINE").ok();
|
||||
|
||||
// Store proxy credentials for Fetch.authRequired handling
|
||||
@@ -1636,6 +1642,7 @@ fn launch_options_from_env() -> LaunchOptions {
|
||||
.unwrap_or(false),
|
||||
color_scheme: env::var("AGENT_BROWSER_COLOR_SCHEME").ok(),
|
||||
download_path: env::var("AGENT_BROWSER_DOWNLOAD_PATH").ok(),
|
||||
viewport_size: None,
|
||||
use_real_keychain: false,
|
||||
}
|
||||
}
|
||||
@@ -1744,6 +1751,7 @@ async fn handle_launch(cmd: &Value, state: &mut DaemonState) -> Result<Value, St
|
||||
.get("downloadPath")
|
||||
.and_then(|v| v.as_str())
|
||||
.map(String::from),
|
||||
viewport_size: None,
|
||||
use_real_keychain: false,
|
||||
};
|
||||
|
||||
|
||||
@@ -974,6 +974,39 @@ impl BrowserManager {
|
||||
Some(session_id),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Screencast captures the actual content area, not the emulated CSS
|
||||
// viewport, so resize the content area to match.
|
||||
if let Ok(target_id) = self.active_target_id() {
|
||||
if let Ok(window_info) = self
|
||||
.client
|
||||
.send_command(
|
||||
"Browser.getWindowForTarget",
|
||||
Some(json!({ "targetId": target_id })),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
if let Some(window_id) = window_info.get("windowId").and_then(|v| v.as_i64()) {
|
||||
if let Err(e) = self
|
||||
.client
|
||||
.send_command(
|
||||
"Browser.setContentsSize",
|
||||
Some(json!({
|
||||
"windowId": window_id,
|
||||
"width": width,
|
||||
"height": height,
|
||||
})),
|
||||
None,
|
||||
)
|
||||
.await
|
||||
{
|
||||
eprintln!("Browser.setContentsSize failed (experimental CDP): {e}");
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
@@ -103,6 +103,9 @@ pub struct LaunchOptions {
|
||||
pub ignore_https_errors: bool,
|
||||
pub color_scheme: Option<String>,
|
||||
pub download_path: Option<String>,
|
||||
/// Initial viewport dimensions used for `--window-size` so the content
|
||||
/// area matches the desired viewport from the start.
|
||||
pub viewport_size: Option<(u32, u32)>,
|
||||
/// When true, omit `--password-store=basic` and `--use-mock-keychain` so
|
||||
/// Chrome uses the real system keychain. Set automatically when launching
|
||||
/// with a copied Chrome profile.
|
||||
@@ -127,6 +130,7 @@ impl Default for LaunchOptions {
|
||||
ignore_https_errors: false,
|
||||
color_scheme: None,
|
||||
download_path: None,
|
||||
viewport_size: None,
|
||||
use_real_keychain: false,
|
||||
}
|
||||
}
|
||||
@@ -222,7 +226,8 @@ fn build_chrome_args(options: &LaunchOptions) -> Result<ChromeArgs, String> {
|
||||
.any(|a| a.starts_with("--start-maximized") || a.starts_with("--window-size="));
|
||||
|
||||
if !has_window_size && options.headless && !has_extensions {
|
||||
args.push("--window-size=1280,720".to_string());
|
||||
let (w, h) = options.viewport_size.unwrap_or((1280, 720));
|
||||
args.push(format!("--window-size={},{}", w, h));
|
||||
}
|
||||
|
||||
args.extend(options.args.iter().cloned());
|
||||
|
||||
@@ -3893,6 +3893,148 @@ async fn e2e_relaunch_on_options_change() {
|
||||
assert_success(&resp);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Stream: custom viewport is reflected in screencast frame metadata
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore]
|
||||
async fn e2e_stream_frame_metadata_respects_custom_viewport() {
|
||||
let guard = EnvGuard::new(&["AGENT_BROWSER_SOCKET_DIR", "AGENT_BROWSER_SESSION"]);
|
||||
let socket_dir = std::env::temp_dir().join(format!(
|
||||
"agent-browser-e2e-stream-viewport-{}-{}",
|
||||
std::process::id(),
|
||||
std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.expect("system clock should be after unix epoch")
|
||||
.as_nanos()
|
||||
));
|
||||
std::fs::create_dir_all(&socket_dir).expect("socket dir should be created");
|
||||
guard.set(
|
||||
"AGENT_BROWSER_SOCKET_DIR",
|
||||
socket_dir.to_str().expect("socket dir should be utf-8"),
|
||||
);
|
||||
guard.set("AGENT_BROWSER_SESSION", "e2e-stream-viewport");
|
||||
|
||||
let mut state = DaemonState::new();
|
||||
|
||||
// Enable stream on an ephemeral port
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "1", "action": "stream_enable", "port": 0 }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
let port = get_data(&resp)["port"]
|
||||
.as_u64()
|
||||
.expect("stream enable should report the bound port");
|
||||
|
||||
// Set a custom viewport before launching the browser
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "2", "action": "viewport", "width": 800, "height": 600 }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
// Connect a WebSocket client
|
||||
let (mut ws, _) = tokio_tungstenite::connect_async(format!("ws://127.0.0.1:{port}"))
|
||||
.await
|
||||
.expect("websocket client should connect to runtime stream");
|
||||
|
||||
// Navigate to trigger browser launch and screencast
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "3", "action": "navigate", "url": "data:text/html,<h1>Viewport Test</h1>" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
// Wait for a frame message and verify both metadata and actual image dimensions
|
||||
let mut found_frame = false;
|
||||
let deadline = tokio::time::Instant::now() + tokio::time::Duration::from_secs(15);
|
||||
while tokio::time::Instant::now() < deadline {
|
||||
let msg = tokio::time::timeout(tokio::time::Duration::from_secs(3), ws.next()).await;
|
||||
let Some(Ok(message)) = msg.ok().flatten() else {
|
||||
continue;
|
||||
};
|
||||
if !message.is_text() {
|
||||
continue;
|
||||
}
|
||||
let parsed: Value =
|
||||
serde_json::from_str(message.to_text().expect("text message should be readable"))
|
||||
.expect("stream payload should be valid JSON");
|
||||
if parsed.get("type") == Some(&json!("frame")) {
|
||||
let meta = &parsed["metadata"];
|
||||
assert_eq!(
|
||||
meta["deviceWidth"], 800,
|
||||
"frame metadata deviceWidth should match custom viewport, got: {}",
|
||||
meta
|
||||
);
|
||||
assert_eq!(
|
||||
meta["deviceHeight"], 600,
|
||||
"frame metadata deviceHeight should match custom viewport, got: {}",
|
||||
meta
|
||||
);
|
||||
|
||||
// Verify the actual JPEG image dimensions match the custom viewport.
|
||||
let data_str = parsed
|
||||
.get("data")
|
||||
.and_then(|v| v.as_str())
|
||||
.expect("frame message should include base64-encoded 'data' field");
|
||||
{
|
||||
use base64::Engine;
|
||||
let bytes = base64::engine::general_purpose::STANDARD
|
||||
.decode(data_str)
|
||||
.expect("frame data should be valid base64");
|
||||
let (img_w, img_h) = jpeg_dimensions(&bytes)
|
||||
.expect("frame data should be a valid JPEG with SOF marker");
|
||||
assert_eq!(
|
||||
img_w, 800,
|
||||
"JPEG image width should match custom viewport, got: {}",
|
||||
img_w
|
||||
);
|
||||
assert_eq!(
|
||||
img_h, 600,
|
||||
"JPEG image height should match custom viewport, got: {}",
|
||||
img_h
|
||||
);
|
||||
}
|
||||
|
||||
found_frame = true;
|
||||
break;
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
found_frame,
|
||||
"should have received at least one frame message with correct viewport metadata"
|
||||
);
|
||||
|
||||
// Cleanup
|
||||
let resp = execute_command(
|
||||
&json!({ "id": "4", "action": "stream_disable" }),
|
||||
&mut state,
|
||||
)
|
||||
.await;
|
||||
assert_success(&resp);
|
||||
|
||||
let resp = execute_command(&json!({ "id": "99", "action": "close" }), &mut state).await;
|
||||
assert_success(&resp);
|
||||
let _ = std::fs::remove_dir_all(&socket_dir);
|
||||
}
|
||||
|
||||
/// Extract width and height from a JPEG's SOF0 (0xFFC0) or SOF2 (0xFFC2) marker.
|
||||
fn jpeg_dimensions(data: &[u8]) -> Option<(u32, u32)> {
|
||||
for i in 0..data.len().saturating_sub(8) {
|
||||
if data[i] == 0xFF && (data[i + 1] == 0xC0 || data[i + 1] == 0xC2) {
|
||||
let height = u16::from_be_bytes([data[i + 5], data[i + 6]]) as u32;
|
||||
let width = u16::from_be_bytes([data[i + 7], data[i + 8]]) as u32;
|
||||
return Some((width, height));
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Upload: ref-based selector support (issue #1107)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -156,8 +156,8 @@ pub(super) async fn cdp_event_loop(
|
||||
"metadata": {
|
||||
"offsetTop": meta.and_then(|m| m.get("offsetTop")).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"pageScaleFactor": meta.and_then(|m| m.get("pageScaleFactor")).and_then(|v| v.as_f64()).unwrap_or(1.0),
|
||||
"deviceWidth": meta.and_then(|m| m.get("deviceWidth")).and_then(|v| v.as_u64()).unwrap_or(1280),
|
||||
"deviceHeight": meta.and_then(|m| m.get("deviceHeight")).and_then(|v| v.as_u64()).unwrap_or(720),
|
||||
"deviceWidth": vw,
|
||||
"deviceHeight": vh,
|
||||
"scrollOffsetX": meta.and_then(|m| m.get("scrollOffsetX")).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"scrollOffsetY": meta.and_then(|m| m.get("scrollOffsetY")).and_then(|v| v.as_f64()).unwrap_or(0.0),
|
||||
"timestamp": meta.and_then(|m| m.get("timestamp")).and_then(|v| v.as_u64()).unwrap_or(0),
|
||||
|
||||
@@ -105,11 +105,18 @@ impl StreamServer {
|
||||
*self.screencasting.lock().await
|
||||
}
|
||||
|
||||
/// Update the stored viewport dimensions used by status messages and screencast.
|
||||
/// Also notifies the screencast event loop to restart with the new dimensions.
|
||||
/// Update the stored viewport dimensions and restart the active screencast (if any)
|
||||
/// so frames are captured at the new size.
|
||||
pub async fn set_viewport(&self, width: u32, height: u32) {
|
||||
*self.viewport_width.lock().await = width;
|
||||
*self.viewport_height.lock().await = height;
|
||||
let mut vw = self.viewport_width.lock().await;
|
||||
let mut vh = self.viewport_height.lock().await;
|
||||
if *vw == width && *vh == height {
|
||||
return;
|
||||
}
|
||||
*vw = width;
|
||||
*vh = height;
|
||||
drop(vw);
|
||||
drop(vh);
|
||||
self.client_notify.notify_one();
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user