Slim browser viewer frame transport

Negotiate binary and slim screencast frames so updated clients avoid base64-in-JSON and repeated viewer metadata on every frame. Clamp screencast capture to the subscriber viewport and DPR, lower streaming JPEG quality while preserving snapshot quality, and teach the client to manage binary frame URLs safely. Document the browser viewer performance plan and update regression coverage for the new transport contract.

Alessandro committed Jul 3, 2026 at 01:49 UTC 91b3bc9613c7ea314623fc04c634929106d99415
6 files changed +417 -107
docs/AGENTS.md
+1
@@ -9,6 +9,7 @@
9
10 - `README.md`, `quickstart.md`, `guides/`, and `setup/` cover user-facing setup and workflows.
11 - `developer/` covers compact developer references and source handoffs.
12 +- `plans/` covers implementation plans, migration notes, and staged technical roadmaps.
13 - `res/` contains documentation images and other documentation assets.
14
15 ## Local Contracts
plugins/_browser/AGENTS.md
+1
@@ -19,6 +19,7 @@
19 - Preserve Playwright lifecycle cleanup and WebSocket viewer compatibility across regular host browsers and Electron WebContentsView embedding.
20 - Keep the WebUI Browser inside its own modal/canvas affordance; do not replace it with page-level navigation.
21 - Default the visible WebUI Browser to live CDP screencast for responsiveness. Keep lightweight CDP/DOM state snapshots as the fallback transport.
22 +- Keep Browser viewer frame transport capability-negotiated: updated clients may request binary/slim screencast frames, while older clients must keep the base64/full-metadata fallback.
23 - Keep narrow WebUI Browser controls usable by grouping navigation with Annotate/settings above a full-width address bar.
24 - Browser URL-intent handling must only claim web URL schemes and leave custom Agent Zero schemes to their owning surfaces.
25 - Prefer DOM/CDP browser actions with refs, selectors, frame-chain refs, and screenshots over viewport coordinate input. Coordinates remain a visual fallback.
plugins/_browser/api/ws_browser.py
+200 -44
@@ -1,6 +1,7 @@
1 from __future__ import annotations
2
3 import asyncio
4 +import base64
5 import contextlib
6 import time
7 from typing import Any, ClassVar
@@ -15,7 +16,8 @@ FRAME_READ_TIMEOUT_SECONDS = 0.5
16 FRAME_RETRY_DELAY_SECONDS = 0.5
17 FRAME_STATE_REFRESH_SECONDS = 0.75
18 SNAPSHOT_STATE_POLL_SECONDS = 0.75
18 -SCREENCAST_QUALITY = 92
19 +SCREENCAST_STREAM_QUALITY = 80
20 +SCREENSHOT_QUALITY = 92
21 VIEWER_TRANSPORT_SCREENCAST = "screencast"
22 VIEWER_TRANSPORT_SNAPSHOT = "snapshot"
23 VIEWER_TRANSPORTS = {VIEWER_TRANSPORT_SCREENCAST, VIEWER_TRANSPORT_SNAPSHOT}
@@ -97,10 +99,21 @@ class WsBrowser(WsHandler):
99 existing.cancel()
100 viewer_id = str(data.get("viewer_id") or "")
101 viewer_transport = self._viewer_transport(data)
102 + binary_frames = self._bool(data.get("binary_frames", data.get("binaryFrames")))
103 + slim_frames = self._bool(data.get("slim_frames", data.get("slimFrames", binary_frames)))
104 + capture_scale = self._capture_scale_from_data(data)
105 snapshot = None
106 if runtime:
107 if viewer_transport == VIEWER_TRANSPORT_SCREENCAST:
103 - stream_task = self._stream_frames(sid, context_id, active_id, viewer_id)
108 + stream_task = self._stream_frames(
109 + sid,
110 + context_id,
111 + active_id,
112 + viewer_id,
113 + binary_frames=binary_frames,
114 + slim_frames=slim_frames,
115 + capture_scale=capture_scale,
116 + )
117 else:
118 stream_task = self._stream_state(sid, context_id, active_id, viewer_id)
119 self._streams[stream_key] = asyncio.create_task(stream_task)
@@ -115,6 +128,8 @@ class WsBrowser(WsHandler):
128 "all_browsers": True,
129 "viewer_id": viewer_id,
130 "viewer_transport": viewer_transport,
131 + "binary_frames": binary_frames,
132 + "slim_frames": slim_frames,
133 }
134
135 def _unsubscribe(self, data: dict[str, Any], sid: str) -> dict[str, Any] | WsResult:
@@ -157,9 +172,9 @@ class WsBrowser(WsHandler):
172 snapshot = None
173 if active_id:
174 try:
160 - quality = int(data.get("quality") or SCREENCAST_QUALITY)
175 + quality = int(data.get("quality") or SCREENSHOT_QUALITY)
176 except (TypeError, ValueError):
162 - quality = SCREENCAST_QUALITY
177 + quality = SCREENSHOT_QUALITY
178 with contextlib.suppress(Exception):
179 snapshot = await runtime.call(
180 "screenshot",
@@ -347,7 +362,7 @@ class WsBrowser(WsHandler):
362 if not browser_id:
363 return None
364 with contextlib.suppress(Exception):
350 - return await runtime.call("screenshot", browser_id, quality=SCREENCAST_QUALITY)
365 + return await runtime.call("screenshot", browser_id, quality=SCREENSHOT_QUALITY)
366 return None
367
368 async def _snapshot_for_browser(
@@ -358,7 +373,7 @@ class WsBrowser(WsHandler):
373 if not browser_id:
374 return None
375 with contextlib.suppress(Exception):
361 - return await runtime.call("screenshot", browser_id, quality=SCREENCAST_QUALITY)
376 + return await runtime.call("screenshot", browser_id, quality=SCREENSHOT_QUALITY)
377 return None
378
379 async def _all_browser_tabs(self) -> list[dict[str, Any]]:
@@ -377,6 +392,10 @@ class WsBrowser(WsHandler):
392 context_id: str,
393 browser_id: int | str | None,
394 viewer_id: str = "",
395 + *,
396 + binary_frames: bool = False,
397 + slim_frames: bool = False,
398 + capture_scale: float = 1.0,
399 ) -> None:
400 runtime = None
401 stream_id = None
@@ -397,12 +416,14 @@ class WsBrowser(WsHandler):
416 browsers = listing.get("browsers") or []
417 active_id = self._active_browser_id(listing, browser_id)
418 if not active_id:
400 - await self._emit_empty_frame(
419 + await self._emit_viewer_state(
420 sid,
421 context_id,
422 + active_id,
423 browsers=browsers,
424 viewer_id=viewer_id,
405 - frame_source=VIEWER_TRANSPORT_SCREENCAST,
425 + state=None,
426 + viewer_transport=VIEWER_TRANSPORT_SCREENCAST,
427 )
428 await asyncio.sleep(FRAME_RETRY_DELAY_SECONDS)
429 continue
@@ -410,29 +431,26 @@ class WsBrowser(WsHandler):
431 screencast = await runtime.call(
432 "start_screencast",
433 active_id,
413 - quality=SCREENCAST_QUALITY,
434 + quality=SCREENCAST_STREAM_QUALITY,
435 every_nth_frame=1,
436 + capture_scale=capture_scale,
437 )
438 stream_id = screencast["stream_id"]
439 active_id = screencast["browser_id"]
440 state = screencast.get("state")
419 - await self.emit_to(
441 + await self._emit_viewer_state(
442 sid,
421 - "browser_viewer_frame",
422 - {
423 - "context_id": context_id,
424 - "viewer_id": viewer_id,
425 - "browser_id": active_id,
426 - "browsers": browsers,
427 - "image": "",
428 - "mime": "",
429 - "state": state,
430 - "frame_source": "state",
431 - "viewer_transport": VIEWER_TRANSPORT_SCREENCAST,
432 - },
443 + context_id,
444 + active_id,
445 + browsers=browsers,
446 + viewer_id=viewer_id,
447 + state=state,
448 + viewer_transport=VIEWER_TRANSPORT_SCREENCAST,
449 )
450
451 last_state_refresh = 0.0
452 + last_state_signature = self._state_signature(active_id, browsers)
453 + frame_sequence = 0
454 while True:
455 now = time.monotonic()
456 if now - last_state_refresh >= FRAME_STATE_REFRESH_SECONDS:
@@ -442,6 +460,18 @@ class WsBrowser(WsHandler):
460 if str(active_id) not in browser_ids:
461 break
462 state = self._state_for_browser(browsers, active_id, state)
463 + state_signature = self._state_signature(active_id, browsers)
464 + if state_signature != last_state_signature:
465 + await self._emit_viewer_state(
466 + sid,
467 + context_id,
468 + active_id,
469 + browsers=browsers,
470 + viewer_id=viewer_id,
471 + state=state,
472 + viewer_transport=VIEWER_TRANSPORT_SCREENCAST,
473 + )
474 + last_state_signature = state_signature
475 last_state_refresh = now
476
477 try:
@@ -455,14 +485,19 @@ class WsBrowser(WsHandler):
485 except TimeoutError:
486 continue
487
458 - frame["context_id"] = context_id
459 - frame["viewer_id"] = viewer_id
460 - frame["browser_id"] = active_id
461 - frame["browsers"] = browsers
462 - frame["state"] = state
463 - frame["frame_source"] = VIEWER_TRANSPORT_SCREENCAST
464 - frame["viewer_transport"] = VIEWER_TRANSPORT_SCREENCAST
465 - await self.emit_to(sid, "browser_viewer_frame", frame)
488 + frame_sequence += 1
489 + payload = self._frame_payload(
490 + frame,
491 + context_id=context_id,
492 + viewer_id=viewer_id,
493 + browser_id=active_id,
494 + sequence=frame_sequence,
495 + binary_frames=binary_frames,
496 + )
497 + if not slim_frames:
498 + payload["browsers"] = browsers
499 + payload["state"] = state
500 + await self._emit_to_connected_viewer(sid, "browser_viewer_frame", payload)
501 except asyncio.CancelledError:
502 raise
503 except Exception:
@@ -515,20 +550,14 @@ class WsBrowser(WsHandler):
550 ),
551 )
552 if signature != last_signature:
518 - await self.emit_to(
553 + await self._emit_viewer_state(
554 sid,
520 - "browser_viewer_frame",
521 - {
522 - "context_id": context_id,
523 - "viewer_id": viewer_id,
524 - "browser_id": active_id,
525 - "browsers": browsers,
526 - "image": "",
527 - "mime": "",
528 - "state": state,
529 - "frame_source": VIEWER_TRANSPORT_SNAPSHOT,
530 - "viewer_transport": VIEWER_TRANSPORT_SNAPSHOT,
531 - },
555 + context_id,
556 + active_id,
557 + browsers=browsers,
558 + viewer_id=viewer_id,
559 + state=state,
560 + viewer_transport=VIEWER_TRANSPORT_SNAPSHOT,
561 )
562 last_signature = signature
563 await asyncio.sleep(SNAPSHOT_STATE_POLL_SECONDS)
@@ -567,6 +596,105 @@ class WsBrowser(WsHandler):
596 return browser
597 return current_state
598
599 + @staticmethod
600 + def _state_signature(
601 + active_id: int | str | None,
602 + browsers: list[dict[str, Any]],
603 + ) -> tuple[str, tuple[tuple[str, str, str, str, bool], ...]]:
604 + return (
605 + str(active_id or ""),
606 + tuple(
607 + (
608 + str(browser.get("context_id") or ""),
609 + str(browser.get("id") or ""),
610 + str(browser.get("currentUrl") or ""),
611 + str(browser.get("title") or ""),
612 + bool(browser.get("loading")),
613 + )
614 + for browser in browsers
615 + ),
616 + )
617 +
618 + @staticmethod
619 + def _frame_payload(
620 + frame: dict[str, Any],
621 + *,
622 + context_id: str,
623 + viewer_id: str,
624 + browser_id: int | str,
625 + sequence: int,
626 + binary_frames: bool,
627 + ) -> dict[str, Any]:
628 + image = str(frame.get("image") or "")
629 + payload: dict[str, Any] = {
630 + "context_id": context_id,
631 + "viewer_id": viewer_id,
632 + "browser_id": browser_id,
633 + "seq": sequence,
634 + "mime": frame.get("mime") or "image/jpeg",
635 + "frame_source": VIEWER_TRANSPORT_SCREENCAST,
636 + "viewer_transport": VIEWER_TRANSPORT_SCREENCAST,
637 + }
638 + dimensions = WsBrowser._frame_dimensions(frame.get("metadata"))
639 + if dimensions:
640 + payload.update(dimensions)
641 + if binary_frames:
642 + try:
643 + payload["image"] = base64.b64decode(image, validate=False)
644 + payload["encoding"] = "binary"
645 + except Exception:
646 + payload["image"] = image
647 + payload["encoding"] = "base64"
648 + else:
649 + payload["image"] = image
650 + payload["encoding"] = "base64"
651 + return payload
652 +
653 + @staticmethod
654 + def _frame_dimensions(metadata: Any) -> dict[str, int]:
655 + if not isinstance(metadata, dict):
656 + return {}
657 + for width_key, height_key in (
658 + ("expectedWidth", "expectedHeight"),
659 + ("deviceWidth", "deviceHeight"),
660 + ("jpegWidth", "jpegHeight"),
661 + ):
662 + try:
663 + width = int(metadata.get(width_key) or 0)
664 + height = int(metadata.get(height_key) or 0)
665 + except (TypeError, ValueError):
666 + continue
667 + if width > 0 and height > 0:
668 + return {"width": width, "height": height}
669 + return {}
670 +
671 + async def _emit_viewer_state(
672 + self,
673 + sid: str,
674 + context_id: str,
675 + browser_id: int | str | None,
676 + *,
677 + browsers: list[dict[str, Any]] | None = None,
678 + viewer_id: str = "",
679 + state: dict[str, Any] | None = None,
680 + viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT,
681 + ) -> None:
682 + await self._emit_to_connected_viewer(
683 + sid,
684 + "browser_viewer_state",
685 + {
686 + "context_id": context_id,
687 + "active_browser_context_id": context_id,
688 + "viewer_id": viewer_id,
689 + "browser_id": browser_id,
690 + "active_browser_id": browser_id,
691 + "browsers": browsers or [],
692 + "state": state,
693 + "all_browsers": False,
694 + "viewer_transport": viewer_transport,
695 + },
696 + )
697 +
698 async def _emit_empty_frame(
699 self,
700 sid: str,
@@ -576,7 +704,7 @@ class WsBrowser(WsHandler):
704 viewer_id: str = "",
705 frame_source: str = "",
706 ) -> None:
579 - await self.emit_to(
707 + await self._emit_to_connected_viewer(
708 sid,
709 "browser_viewer_frame",
710 {
@@ -592,6 +720,20 @@ class WsBrowser(WsHandler):
720 },
721 )
722
723 + async def _emit_to_connected_viewer(
724 + self,
725 + sid: str,
726 + event: str,
727 + data: dict[str, Any],
728 + ) -> None:
729 + manager = getattr(self, "_manager", None)
730 + if manager is not None:
731 + with manager.lock:
732 + connected = (getattr(self, "namespace", "/ws"), sid) in manager.connections
733 + if not connected:
734 + raise asyncio.CancelledError()
735 + await self.emit_to(sid, event, data)
736 +
737 @staticmethod
738 def _viewer_transport(data: dict[str, Any]) -> str:
739 raw = (
@@ -623,6 +765,20 @@ class WsBrowser(WsHandler):
765 "height": max(200, min(4096, height)),
766 }
767
768 + @staticmethod
769 + def _capture_scale_from_data(data: dict[str, Any]) -> float:
770 + try:
771 + scale = float(
772 + data.get("device_pixel_ratio")
773 + or data.get("devicePixelRatio")
774 + or data.get("pixel_ratio")
775 + or data.get("pixelRatio")
776 + or 1
777 + )
778 + except (TypeError, ValueError):
779 + return 1.0
780 + return max(1.0, min(2.0, scale))
781 +
782 @staticmethod
783 def _context_id(data: dict[str, Any]) -> str:
784 return str(data.get("context_id") or data.get("context") or "").strip()
plugins/_browser/helpers/runtime.py
+8 -2
@@ -329,10 +329,14 @@ class _BrowserScreencast:
329 quality: int,
330 every_nth_frame: int,
331 viewport: dict[str, int],
332 + capture_scale: float = 1.0,
333 ) -> None:
334 self.session.on("Page.screencastFrame", self._on_frame)
335 width = max(320, min(4096, int(viewport.get("width") or DEFAULT_VIEWPORT["width"])))
336 height = max(200, min(4096, int(viewport.get("height") or DEFAULT_VIEWPORT["height"])))
337 + scale = max(1.0, min(2.0, float(capture_scale or 1.0)))
338 + max_width = max(320, min(SCREENCAST_MAX_WIDTH, int(round(width * scale))))
339 + max_height = max(200, min(SCREENCAST_MAX_HEIGHT, int(round(height * scale))))
340 self._expected_width = width
341 self._expected_height = height
342 with contextlib.suppress(Exception):
@@ -343,8 +347,8 @@ class _BrowserScreencast:
347 {
348 "format": "jpeg",
349 "quality": max(20, min(95, int(quality))),
346 - "maxWidth": SCREENCAST_MAX_WIDTH,
347 - "maxHeight": SCREENCAST_MAX_HEIGHT,
350 + "maxWidth": max_width,
351 + "maxHeight": max_height,
352 "everyNthFrame": max(1, int(every_nth_frame)),
353 },
354 )
@@ -1616,6 +1620,7 @@ class _BrowserRuntimeCore:
1620 *,
1621 quality: int = 78,
1622 every_nth_frame: int = 1,
1623 + capture_scale: float = 1.0,
1624 ) -> dict[str, Any]:
1625 await self.ensure_started()
1626 resolved_id = self._resolve_browser_id(browser_id)
@@ -1634,6 +1639,7 @@ class _BrowserRuntimeCore:
1639 quality=quality,
1640 every_nth_frame=every_nth_frame,
1641 viewport=page.viewport_size or DEFAULT_VIEWPORT,
1642 + capture_scale=capture_scale,
1643 )
1644 except Exception:
1645 self.screencasts.pop(stream_id, None)
plugins/_browser/webui/browser-store.js
+152 -53
@@ -34,6 +34,8 @@ const ANNOTATION_DOM_LIMIT = 1200;
34 const ANNOTATION_TRAY_MARGIN = 10;
35 const BROWSER_VISUAL_SHORTCUT_KEYS = new Set(["a", "c", "insert", "v", "x", "y", "z"]);
36 const LOCAL_EDITABLE_SELECTOR = "input, textarea, select, [contenteditable]";
37 +const BROWSER_BINARY_FRAMES_SUPPORTED = typeof Blob === "function"
38 + && typeof globalThis.URL?.createObjectURL === "function";
39
40 function makeViewerToken() {
41 return globalThis.crypto?.randomUUID?.()
@@ -114,6 +116,31 @@ function loadFrameDimensions(src) {
116 });
117 }
118
119 +function frameImageSource(data = {}) {
120 + const image = data?.image;
121 + if (!image) return null;
122 + const mime = data.mime || "image/jpeg";
123 + const isArrayBuffer = typeof ArrayBuffer !== "undefined" && image instanceof ArrayBuffer;
124 + const isView = typeof ArrayBuffer !== "undefined" && ArrayBuffer.isView?.(image);
125 + const isBlob = typeof Blob !== "undefined" && image instanceof Blob;
126 + if (data.encoding === "binary" || isArrayBuffer || isView || isBlob) {
127 + if (!BROWSER_BINARY_FRAMES_SUPPORTED) return null;
128 + const blob = isBlob ? image : new Blob([image], { type: mime });
129 + const src = globalThis.URL.createObjectURL(blob);
130 + return {
131 + src,
132 + objectUrl: src,
133 + cleanup: () => globalThis.URL.revokeObjectURL(src),
134 + };
135 + }
136 + if (typeof image !== "string") return null;
137 + return {
138 + src: `data:${mime};base64,${image}`,
139 + objectUrl: "",
140 + cleanup: null,
141 + };
142 +}
143 +
144 const model = {
145 loading: true,
146 error: "",
@@ -146,6 +173,7 @@ const model = {
173 _lastFrameDimensions: null,
174 _pendingFrameSrc: "",
175 _pendingFrameOptions: null,
176 + _frameObjectUrl: "",
177 _frameRenderHandle: null,
178 _frameRenderCancel: null,
179 _frameRenderSequence: 0,
@@ -442,7 +470,7 @@ const model = {
470 this.setActiveBrowserId(null);
471 this.address = "";
472 this.frameState = null;
445 - this.frameSrc = "";
473 + this.clearFrameSrc();
474 if (this.contextId) {
475 await this.connectViewer();
476 }
@@ -833,7 +861,7 @@ const model = {
861
862 resetRenderedFrame() {
863 this.cancelFrameRender();
836 - this.frameSrc = "";
864 + this.clearFrameSrc();
865 this._lastFrameDimensions = null;
866 this._lastFrameAt = 0;
867 },
@@ -915,6 +943,25 @@ const model = {
943 return this.viewerTransport === BROWSER_VIEWER_TRANSPORT_SCREENCAST;
944 },
945
946 + supportsBinaryFrames() {
947 + return BROWSER_BINARY_FRAMES_SUPPORTED;
948 + },
949 +
950 + captureDevicePixelRatio() {
951 + const value = Number(globalThis.devicePixelRatio || 1);
952 + if (!Number.isFinite(value) || value <= 1) return 1;
953 + return Math.min(2, value);
954 + },
955 +
956 + frameDimensionsFromData(data = null) {
957 + const width = Number(data?.width || 0);
958 + const height = Number(data?.height || 0);
959 + if (Number.isFinite(width) && Number.isFinite(height) && width > 0 && height > 0) {
960 + return { width, height };
961 + }
962 + return this.frameDimensionsFromMetadata(data?.metadata);
963 + },
964 +
965 frameDimensionsFromMetadata(metadata = null) {
966 if (!metadata || typeof metadata !== "object") return null;
967 const width = Number(metadata.expectedWidth || metadata.deviceWidth || metadata.jpegWidth || 0);
@@ -986,6 +1033,9 @@ const model = {
1033 viewer_id: viewerToken,
1034 create_browser: Boolean(options.createBrowser || options.create_browser),
1035 viewer_transport: this.requestedViewerTransport(),
1036 + binary_frames: this.supportsBinaryFrames(),
1037 + slim_frames: true,
1038 + device_pixel_ratio: this.captureDevicePixelRatio(),
1039 viewport_width: initialViewport?.width,
1040 viewport_height: initialViewport?.height,
1041 },
@@ -1014,9 +1064,9 @@ const model = {
1064 data.active_browser_context_id || contextId,
1065 );
1066 this.applySnapshot(data.snapshot);
1017 - this.connected = true;
1018 - this.browserInstallExpected = false;
1019 - },
1067 + this.connected = true;
1068 + this.browserInstallExpected = false;
1069 + },
1070
1071 async _bindSocketEvents() {
1072 if (!this._frameOff) {
@@ -1028,7 +1078,9 @@ const model = {
1078 }
1079 const incomingContextId = this.normalizeContextId(data.context_id || this.contextId);
1080 const incomingBrowserId = this.normalizeBrowserId(data.browser_id || data.state?.id);
1031 - this.applyBrowserListing(data.browsers || [], incomingContextId, { replaceContext: true });
1081 + if (Array.isArray(data.browsers)) {
1082 + this.applyBrowserListing(data.browsers, incomingContextId, { replaceContext: true });
1083 + }
1084 if (incomingBrowserId && !this.activeBrowserId) {
1085 this.setActiveBrowserId(incomingBrowserId, incomingContextId);
1086 }
@@ -1046,11 +1098,15 @@ const model = {
1098 this.address = data.state.currentUrl;
1099 }
1100 if (data.image) {
1101 + const frameImage = frameImageSource(data);
1102 + if (!frameImage?.src) return;
1103 const frameBrowserId = incomingBrowserId || this.activeBrowserId;
1050 - this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`, {
1104 + this.queueFrameRender(frameImage.src, {
1105 browserId: frameBrowserId,
1106 contextId: incomingContextId,
1053 - dimensions: this.frameDimensionsFromMetadata(data.metadata),
1107 + dimensions: this.frameDimensionsFromData(data),
1108 + objectUrl: frameImage.objectUrl,
1109 + cleanup: frameImage.cleanup,
1110 onAccepted: () => {
1111 if (
1112 this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
@@ -1063,13 +1119,13 @@ const model = {
1119 });
1120 } else if (!data.state) {
1121 this.cancelFrameRender();
1066 - this.frameSrc = "";
1122 + this.clearFrameSrc();
1123 }
1124 if (!data.image && !data.state) {
1125 if (!this.activeBrowserId) {
1126 this.setActiveBrowserId(null, "");
1127 this.frameState = null;
1072 - this.frameSrc = "";
1128 + this.clearFrameSrc();
1129 }
1130 }
1131 this._lastFrameAt = Date.now();
@@ -1077,15 +1133,20 @@ const model = {
1133 await websocket.on("browser_viewer_frame", frameHandler);
1134 this._frameOff = () => websocket.off("browser_viewer_frame", frameHandler);
1135 }
1080 - if (!this._stateOff) {
1081 - const stateHandler = ({ data }) => {
1082 - if (data?.context_id !== this.contextId) return;
1083 - if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
1084 - if (data?.viewer_transport) {
1085 - this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1086 - }
1136 + if (!this._stateOff) {
1137 + const stateHandler = ({ data }) => {
1138 + if (data?.context_id !== this.contextId) return;
1139 + if (data?.viewer_id && data.viewer_id !== this._viewerToken) return;
1140 + if (data?.viewer_transport) {
1141 + this.viewerTransport = this.normalizeViewerTransport(data.viewer_transport);
1142 + }
1143 const commandContextId = this.normalizeContextId(data.active_browser_context_id || data.context_id || this.contextId);
1088 - this.applyBrowserListing(data.browsers || [], commandContextId, { replaceAll: Boolean(data.all_browsers) });
1144 + if (Array.isArray(data.browsers)) {
1145 + this.applyBrowserListing(data.browsers, commandContextId, {
1146 + replaceAll: Boolean(data.all_browsers),
1147 + replaceContext: !data.all_browsers,
1148 + });
1149 + }
1150 const command = String(data.command || "").toLowerCase();
1151 const commandBrowserId = this.normalizeBrowserId(data.browser_id);
1152 const result = data.result || {};
@@ -1102,23 +1163,40 @@ const model = {
1163 || this.activeBrowserId
1164 || this.firstBrowserId(resultContextId)
1165 );
1105 - if (
1106 - !this.activeBrowserId
1107 - || command === "open"
1108 - || command === "close"
1109 - || this.sameBrowserTab(commandBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
1110 - ) {
1111 - this.setActiveBrowserId(preferredBrowserId, resultContextId);
1112 - }
1113 - this.applyActiveFrameState(resultState || this.browserById(this.activeBrowserId, this.activeBrowserContextId));
1114 - this.applySnapshot(data.snapshot);
1115 - };
1166 + const stateBrowserId = this.normalizeBrowserId(data.active_browser_id || data.browser_id || data.state?.id);
1167 + if (
1168 + stateBrowserId
1169 + && (
1170 + !this.activeBrowserId
1171 + || this.sameBrowserTab(stateBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
1172 + )
1173 + ) {
1174 + this.setActiveBrowserId(stateBrowserId, commandContextId);
1175 + }
1176 + if (
1177 + !this.activeBrowserId
1178 + || command === "open"
1179 + || command === "close"
1180 + || this.sameBrowserTab(commandBrowserId, commandContextId, this.activeBrowserId, this.activeBrowserContextId)
1181 + ) {
1182 + this.setActiveBrowserId(preferredBrowserId, resultContextId);
1183 + }
1184 + this.applyActiveFrameState(
1185 + resultState
1186 + || data.state
1187 + || this.browserById(this.activeBrowserId, this.activeBrowserContextId)
1188 + );
1189 + this.applySnapshot(data.snapshot);
1190 + };
1191 await websocket.on("browser_viewer_state", stateHandler);
1192 this._stateOff = () => websocket.off("browser_viewer_state", stateHandler);
1193 }
1194 },
1195
1196 queueFrameRender(frameSrc, options = {}) {
1197 + if (this._pendingFrameSrc) {
1198 + this.releasePendingFrame();
1199 + }
1200 this._pendingFrameSrc = frameSrc;
1201 this._pendingFrameOptions = options || null;
1202 if (this._frameRenderHandle) return;
@@ -1148,22 +1226,26 @@ const model = {
1226 async renderDecodedFrame(frameSrc, options = {}, sequence = 0, surfaceSequence = this._surfaceOpenSequence) {
1227 if (!frameSrc) {
1228 if (sequence === this._frameRenderSequence) {
1151 - this.frameSrc = "";
1229 + this.clearFrameSrc();
1230 }
1231 return;
1232 }
1233 const dimensions = options?.dimensions || await loadFrameDimensions(frameSrc);
1234 if (sequence !== this._frameRenderSequence || surfaceSequence !== this._surfaceOpenSequence) {
1235 + options?.cleanup?.();
1236 return;
1237 }
1238 const viewport = this.currentViewportSize() || this._lastViewport;
1239 if (!this.frameMatchesViewport(dimensions, viewport)) {
1240 this.requestViewportSyncAfterRejectedFrame();
1241 if (!this.shouldAcceptMismatchedFrame(dimensions)) {
1242 + options?.cleanup?.();
1243 return;
1244 }
1245 }
1246 + this.releaseRenderedFrameUrl(frameSrc);
1247 this.frameSrc = frameSrc;
1248 + this._frameObjectUrl = options?.objectUrl || "";
1249 this._lastFrameDimensions = dimensions;
1250 this._lastFrameAt = Date.now();
1251 options?.onAccepted?.();
@@ -1262,9 +1344,26 @@ const model = {
1344 }
1345 this._frameRenderHandle = null;
1346 this._frameRenderCancel = null;
1347 + this.releasePendingFrame();
1348 + this._frameRenderSequence += 1;
1349 + },
1350 +
1351 + releasePendingFrame() {
1352 + this._pendingFrameOptions?.cleanup?.();
1353 this._pendingFrameSrc = "";
1354 this._pendingFrameOptions = null;
1267 - this._frameRenderSequence += 1;
1355 + },
1356 +
1357 + releaseRenderedFrameUrl(nextSrc = "") {
1358 + if (this._frameObjectUrl && this._frameObjectUrl !== nextSrc) {
1359 + globalThis.URL?.revokeObjectURL?.(this._frameObjectUrl);
1360 + this._frameObjectUrl = "";
1361 + }
1362 + },
1363 +
1364 + clearFrameSrc() {
1365 + this.releaseRenderedFrameUrl("");
1366 + this.frameSrc = "";
1367 },
1368
1369 beginCommand() {
@@ -1329,7 +1428,7 @@ const model = {
1428 );
1429 if (!this.activeBrowserId) {
1430 this.frameState = null;
1332 - this.frameSrc = "";
1431 + this.clearFrameSrc();
1432 }
1433 if (result.state?.currentUrl || result.currentUrl) {
1434 this.address = result.state?.currentUrl || result.currentUrl;
@@ -1438,7 +1537,7 @@ const model = {
1537 this.error = "";
1538 this.switchingBrowserId = targetId;
1539 this.cancelFrameRender();
1441 - this.frameSrc = "";
1540 + this.clearFrameSrc();
1541 this.frameState = browser || null;
1542 if (!this.addressFocused && browser?.currentUrl) {
1543 this.address = browser.currentUrl;
@@ -1643,35 +1742,35 @@ const model = {
1742 }
1743 },
1744
1646 - applySnapshot(snapshot = null) {
1647 - if (!snapshot?.image) return;
1648 - const snapshotId = this.normalizeBrowserId(snapshot.browser_id || snapshot.state?.id);
1745 + applySnapshot(snapshot = null) {
1746 + if (!snapshot?.image) return;
1747 + const snapshotId = this.normalizeBrowserId(snapshot.browser_id || snapshot.state?.id);
1748 const snapshotContextId = this.normalizeContextId(snapshot.context_id || snapshot.state?.context_id || this.activeBrowserContextId);
1650 - if (
1749 + if (
1750 snapshotId
1751 && this.activeBrowserId
1752 && !this.sameBrowserTab(snapshotId, snapshotContextId, this.activeBrowserId, this.activeBrowserContextId)
1753 ) {
1655 - return;
1656 - }
1657 - if (snapshot.state) {
1658 - this.applyActiveFrameState(snapshot.state);
1659 - }
1660 - const frameBrowserId = snapshotId || this.activeBrowserId;
1661 - this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
1662 - browserId: frameBrowserId,
1754 + return;
1755 + }
1756 + if (snapshot.state) {
1757 + this.applyActiveFrameState(snapshot.state);
1758 + }
1759 + const frameBrowserId = snapshotId || this.activeBrowserId;
1760 + this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
1761 + browserId: frameBrowserId,
1762 contextId: snapshotContextId,
1664 - onAccepted: () => {
1665 - if (
1763 + onAccepted: () => {
1764 + if (
1765 this.sameBrowserId(this.switchingBrowserId, frameBrowserId)
1766 && this.normalizeContextId(this.activeBrowserContextId) === snapshotContextId
1767 ) {
1669 - this.switchingBrowserId = null;
1670 - }
1671 - this._surfaceSwitching = false;
1672 - },
1673 - });
1674 - },
1768 + this.switchingBrowserId = null;
1769 + }
1770 + this._surfaceSwitching = false;
1771 + },
1772 + });
1773 + },
1774
1775 isSwitchingBrowser() {
1776 return Boolean(
tests/test_browser_agent_regressions.py
+55 -8
@@ -1158,7 +1158,8 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1158 assert "def _viewer_transport(data: dict[str, Any])" in ws_browser
1159 assert "return VIEWER_TRANSPORT_SNAPSHOT" in ws_browser
1160 assert "self._stream_state" in ws_browser
1161 - assert "SCREENCAST_QUALITY = 92" in ws_browser
1161 + assert "SCREENCAST_STREAM_QUALITY = 80" in ws_browser
1162 + assert "SCREENSHOT_QUALITY = 92" in ws_browser
1163 assert "initial_viewport = self._viewport_from_data(data)" in ws_browser
1164 assert '"set_viewport"' in ws_browser
1165 assert "start_screencast" in ws_browser
@@ -1169,7 +1170,7 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1170 assert "stop_screencast" in ws_browser
1171 assert "viewer_transport == VIEWER_TRANSPORT_SCREENCAST" in ws_browser
1172 assert '"viewer_transport": viewer_transport' in ws_browser
1172 - assert '"viewer_transport": VIEWER_TRANSPORT_SNAPSHOT' in ws_browser
1173 + assert "viewer_transport: str = VIEWER_TRANSPORT_SNAPSHOT" in ws_browser
1174 assert '"Page.startScreencast"' in runtime
1175 assert '"Page.screencastFrame"' in runtime
1176 assert '"Page.screencastFrameAck"' in runtime
@@ -1180,6 +1181,7 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1181 assert "await self._stop_screencasts_for_browser(resolved_id)" in runtime
1182 assert "queueFrameRender" in browser_store
1183 assert "requestAnimationFrame" in browser_store
1184 + assert "function frameImageSource(data = {})" in browser_store
1185 assert 'const BROWSER_VIEWER_TRANSPORT_SNAPSHOT = "snapshot";' in browser_store
1186 assert 'const BROWSER_VIEWER_TRANSPORT_SCREENCAST = "screencast";' in browser_store
1187 assert "viewerTransport: BROWSER_VIEWER_TRANSPORT_SCREENCAST" in browser_store
@@ -1187,11 +1189,17 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1189 assert "requestedViewerTransport()" in browser_store
1190 assert "normalizeViewerTransport(value = \"\")" in browser_store
1191 assert "usesScreencastTransport()" in browser_store
1192 + assert "supportsBinaryFrames()" in browser_store
1193 + assert "captureDevicePixelRatio()" in browser_store
1194 + assert "frameDimensionsFromData(data = null)" in browser_store
1195 assert "frameDimensionsFromMetadata(metadata = null)" in browser_store
1196 assert "metadata.expectedWidth || metadata.deviceWidth || metadata.jpegWidth" in browser_store
1192 - assert "dimensions: this.frameDimensionsFromMetadata(data.metadata)" in browser_store
1197 + assert "dimensions: this.frameDimensionsFromData(data)" in browser_store
1198 assert "const dimensions = options?.dimensions || await loadFrameDimensions(frameSrc)" in browser_store
1199 assert "viewer_transport: this.requestedViewerTransport()" in browser_store
1200 + assert "binary_frames: this.supportsBinaryFrames()" in browser_store
1201 + assert "slim_frames: true" in browser_store
1202 + assert "device_pixel_ratio: this.captureDevicePixelRatio()" in browser_store
1203 assert "viewport_width: initialViewport?.width" in browser_store
1204 assert "viewport_height: initialViewport?.height" in browser_store
1205 assert "restart_stream: restartStream && this.usesScreencastTransport()" in browser_store
@@ -1213,6 +1221,13 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1221 assert "this.applySnapshot(data.snapshot);" in browser_store
1222 assert "else if (!data.state)" in browser_store
1223 assert '"snapshot": snapshot' in ws_browser
1224 + assert '"binary_frames": binary_frames' in ws_browser
1225 + assert '"slim_frames": slim_frames' in ws_browser
1226 + assert "capture_scale=capture_scale" in ws_browser
1227 + assert "base64.b64decode(image, validate=False)" in ws_browser
1228 + assert '"encoding"] = "binary"' in ws_browser
1229 + assert "def _frame_payload(" in ws_browser
1230 + assert "def _emit_viewer_state(" in ws_browser
1231 assert 'const BROWSER_SNAPSHOT_META_KEY = "browser_snapshot";' in browser_tool_handler
1232 assert "staticScreenshotUri(kvps)" in browser_tool_handler
1233 assert "/components/modals/image-viewer/image-viewer-store.js" in browser_tool_handler
@@ -1223,9 +1238,9 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1238 assert "delete displayKvps[BROWSER_SNAPSHOT_META_KEY];" in browser_tool_handler
1239 assert "startBrowserScreenshotPreview(button, image, resolveBrowserPayload)" in browser_tool_handler
1240 assert "FRAME_FALLBACK_SCREENSHOT_SECONDS" not in ws_browser
1226 - assert '"frame_source": "state"' in ws_browser
1227 - assert '"frame_source"] = VIEWER_TRANSPORT_SCREENCAST' in ws_browser
1228 - assert '"viewer_transport"] = VIEWER_TRANSPORT_SCREENCAST' in ws_browser
1241 + assert '"browser_viewer_state"' in ws_browser
1242 + assert '"frame_source": VIEWER_TRANSPORT_SCREENCAST' in ws_browser
1243 + assert '"viewer_transport": VIEWER_TRANSPORT_SCREENCAST' in ws_browser
1244 assert "fallback_screenshot" not in ws_browser
1245 assert "canvas_wheel_screenshot" not in ws_browser
1246 assert "surface_mode: this._mode" not in browser_store
@@ -1234,6 +1249,29 @@ def test_browser_viewer_defaults_to_live_screencast_with_snapshot_fallback():
1249 assert "image-rendering: auto;" in main_html
1250
1251
1252 +def test_browser_viewer_frame_payload_supports_binary_slim_frames():
1253 + payload = ws_browser_module.WsBrowser._frame_payload(
1254 + {
1255 + "image": SMALL_JPEG_10X10,
1256 + "mime": "image/jpeg",
1257 + "metadata": {"expectedWidth": 900, "expectedHeight": 600},
1258 + },
1259 + context_id="ctx",
1260 + viewer_id="viewer",
1261 + browser_id=1,
1262 + sequence=3,
1263 + binary_frames=True,
1264 + )
1265 +
1266 + assert payload["encoding"] == "binary"
1267 + assert payload["image"] == __import__("base64").b64decode(SMALL_JPEG_10X10)
1268 + assert payload["width"] == 900
1269 + assert payload["height"] == 600
1270 + assert payload["seq"] == 3
1271 + assert "browsers" not in payload
1272 + assert "state" not in payload
1273 +
1274 +
1275 def test_browser_navigation_errors_stay_inside_native_browser_page():
1276 runtime = (
1277 PROJECT_ROOT / "plugins" / "_browser" / "helpers" / "runtime.py"
@@ -1504,7 +1542,12 @@ async def test_browser_screencast_acknowledges_and_drops_stale_frames():
1542 mime="image/jpeg",
1543 )
1544
1507 - await screencast.start(quality=92, every_nth_frame=1, viewport={"width": 1118, "height": 662})
1545 + await screencast.start(
1546 + quality=92,
1547 + every_nth_frame=1,
1548 + viewport={"width": 1118, "height": 662},
1549 + capture_scale=2,
1550 + )
1551 session.handlers["Page.screencastFrame"](
1552 {"data": first_image, "metadata": {"deviceWidth": 10}, "sessionId": 1}
1553 )
@@ -1563,6 +1606,10 @@ async def test_browser_screencast_acknowledges_and_drops_stale_frames():
1606 for index, (method, _params) in enumerate(session.sent)
1607 if method == "Page.startScreencast"
1608 )
1609 + start_params = session.sent[start_index][1]
1610 + assert start_params["quality"] == 92
1611 + assert start_params["maxWidth"] == 2236
1612 + assert start_params["maxHeight"] == 1324
1613 cdp_viewport_indices = [
1614 index
1615 for index, (method, _params) in enumerate(session.sent)
@@ -2287,7 +2334,7 @@ async def test_browser_viewer_subscribe_returns_initial_snapshot(monkeypatch):
2334
2335 assert result["active_browser_id"] == 1
2336 assert result["snapshot"]["image"] == "jpeg-data"
2290 - assert ("screenshot", (1,), {"quality": ws_browser_module.SCREENCAST_QUALITY}) in calls
2337 + assert ("screenshot", (1,), {"quality": ws_browser_module.SCREENSHOT_QUALITY}) in calls
2338
2339 await handler.on_disconnect("sid-snapshot")
2340