Stabilize browser viewer viewport rendering
Decode browser frames before display and only render frames that match the active viewer viewport, avoiding stretched stale screencast images during startup and resize. Keep rejecting mismatched CDP screencast frames on the backend, extend canvas viewport settling, and cover the behavior with browser regression tests. Include small browser panel CSS polish.
Alessandro committed
Apr 28, 2026 at 03:50 UTC
decb05a6823b4a04fddafa2357ea8155c766e638
4 files changed
+177
-34
plugins/_browser/helpers/runtime.py
+5
-5
@@ -111,7 +111,6 @@ class _BrowserScreencast:
111
self._ack_tasks: set[asyncio.Task] = set()
112
self._expected_width = 0
113
self._expected_height = 0
114
- self._dimension_mismatches = 0
114
115
async def start(
116
self,
@@ -125,7 +124,6 @@ class _BrowserScreencast:
124
height = max(200, min(4096, int(viewport.get("height") or DEFAULT_VIEWPORT["height"])))
125
self._expected_width = width
126
self._expected_height = height
128
- self._dimension_mismatches = 0
127
with contextlib.suppress(Exception):
128
await self.session.send("Page.enable")
129
await self.session.send(
@@ -229,10 +227,12 @@ class _BrowserScreencast:
227
if not size:
228
return True
229
width, height = size
232
- if abs(width - self._expected_width) <= 2 and abs(height - self._expected_height) <= 2:
230
+ if (
231
+ abs(width - self._expected_width) <= VIEWPORT_SIZE_TOLERANCE
232
+ and abs(height - self._expected_height) <= VIEWPORT_SIZE_TOLERANCE
233
+ ):
234
return True
234
- self._dimension_mismatches += 1
235
- return self._dimension_mismatches > 10
235
+ return False
236
237
@staticmethod
238
def _jpeg_size(data: str) -> tuple[int, int] | None:
plugins/_browser/webui/browser-panel.html
+1
-1
@@ -620,7 +620,7 @@
620
align-items: end;
621
gap: 6px;
622
min-width: 0;
623
- padding-bottom: 1px;
623
+ padding-bottom: 7px;
624
}
625
626
.browser-annotate-toggle {
plugins/_browser/webui/browser-store.js
+113
-14
@@ -13,8 +13,9 @@ const BROWSER_FIRST_INSTALL_TIMEOUT_MS = 300000;
13
const BROWSER_CONFIG_REFRESH_MS = 15000;
14
const VIEWPORT_SYNC_DEBOUNCE_MS = 220;
15
const VIEWPORT_SYNC_SIZE_TOLERANCE = 4;
16
-const CANVAS_VIEWPORT_SETTLE_MS = 260;
17
-const SURFACE_VIEWPORT_STABLE_FRAMES = 2;
16
+const CANVAS_VIEWPORT_SETTLE_MS = 520;
17
+const SURFACE_VIEWPORT_STABLE_FRAMES = 4;
18
+const FRAME_REJECT_SYNC_COOLDOWN_MS = 600;
19
const ANNOTATION_DRAG_THRESHOLD = 6;
20
const ANNOTATION_MAX_COMMENTS = 24;
21
const ANNOTATION_DOM_LIMIT = 1200;
@@ -55,6 +56,34 @@ function nextAnimationFrame() {
56
});
57
}
58
59
+function loadFrameDimensions(src) {
60
+ return new Promise((resolve) => {
61
+ if (!src) {
62
+ resolve(null);
63
+ return;
64
+ }
65
+
66
+ const image = new Image();
67
+ let settled = false;
68
+ const finish = (dimensions) => {
69
+ if (settled) return;
70
+ settled = true;
71
+ resolve(dimensions);
72
+ };
73
+
74
+ image.onload = () => finish({
75
+ width: image.naturalWidth || 0,
76
+ height: image.naturalHeight || 0,
77
+ });
78
+ image.onerror = () => finish(null);
79
+ image.src = src;
80
+
81
+ if (image.complete) {
82
+ image.onload();
83
+ }
84
+ });
85
+}
86
+
87
const model = {
88
loading: true,
89
error: "",
@@ -79,13 +108,17 @@ const model = {
108
_frameOff: null,
109
_stateOff: null,
110
_lastFrameAt: 0,
111
+ _lastFrameDimensions: null,
112
_pendingFrameSrc: "",
113
+ _pendingFrameOptions: null,
114
_frameRenderHandle: null,
115
_frameRenderCancel: null,
116
+ _frameRenderSequence: 0,
117
_floatingCleanup: null,
118
_stageElement: null,
119
_stageResizeObserver: null,
120
_viewportSyncTimer: null,
121
+ _lastFrameRejectSyncAt: 0,
122
_lastViewportKey: "",
123
_lastViewport: null,
124
_annotationPointer: null,
@@ -466,6 +499,7 @@ const model = {
499
resetRenderedFrame() {
500
this.cancelFrameRender();
501
this.frameSrc = "";
502
+ this._lastFrameDimensions = null;
503
this._lastFrameAt = 0;
504
},
505
@@ -599,11 +633,16 @@ const model = {
633
this.address = data.state.currentUrl;
634
}
635
if (data.image) {
602
- this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`);
603
- if (this.sameBrowserId(this.switchingBrowserId, incomingBrowserId || this.activeBrowserId)) {
604
- this.switchingBrowserId = null;
605
- }
606
- this._surfaceSwitching = false;
636
+ const frameBrowserId = incomingBrowserId || this.activeBrowserId;
637
+ this.queueFrameRender(`data:${data.mime || "image/jpeg"};base64,${data.image}`, {
638
+ browserId: frameBrowserId,
639
+ onAccepted: () => {
640
+ if (this.sameBrowserId(this.switchingBrowserId, frameBrowserId)) {
641
+ this.switchingBrowserId = null;
642
+ }
643
+ this._surfaceSwitching = false;
644
+ },
645
+ });
646
} else {
647
this.cancelFrameRender();
648
if (!data.state) {
@@ -654,8 +693,9 @@ const model = {
693
}
694
},
695
657
- queueFrameRender(frameSrc) {
696
+ queueFrameRender(frameSrc, options = {}) {
697
this._pendingFrameSrc = frameSrc;
698
+ this._pendingFrameOptions = options || null;
699
if (this._frameRenderHandle) return;
700
const schedule = globalThis.requestAnimationFrame?.bind(globalThis);
701
if (schedule) {
@@ -670,8 +710,59 @@ const model = {
710
flushFrameRender() {
711
this._frameRenderHandle = null;
712
this._frameRenderCancel = null;
673
- this.frameSrc = this._pendingFrameSrc || "";
713
+ const frameSrc = this._pendingFrameSrc || "";
714
+ const options = this._pendingFrameOptions || {};
715
this._pendingFrameSrc = "";
716
+ this._pendingFrameOptions = null;
717
+ const sequence = this._frameRenderSequence + 1;
718
+ this._frameRenderSequence = sequence;
719
+ void this.renderDecodedFrame(frameSrc, options, sequence);
720
+ },
721
+
722
+ async renderDecodedFrame(frameSrc, options = {}, sequence = 0) {
723
+ if (!frameSrc) {
724
+ if (sequence === this._frameRenderSequence) {
725
+ this.frameSrc = "";
726
+ }
727
+ return;
728
+ }
729
+ const dimensions = await loadFrameDimensions(frameSrc);
730
+ if (sequence !== this._frameRenderSequence) return;
731
+ const viewport = this.currentViewportSize() || this._lastViewport;
732
+ if (!this.frameMatchesViewport(dimensions, viewport)) {
733
+ this.requestViewportSyncAfterRejectedFrame();
734
+ return;
735
+ }
736
+ this.frameSrc = frameSrc;
737
+ this._lastFrameDimensions = dimensions;
738
+ this._lastFrameAt = Date.now();
739
+ options?.onAccepted?.();
740
+ },
741
+
742
+ frameMatchesViewport(dimensions = null, viewport = null) {
743
+ if (!dimensions?.width || !dimensions?.height || !viewport?.width || !viewport?.height) {
744
+ return false;
745
+ }
746
+ return Math.abs(Number(dimensions.width) - Number(viewport.width)) <= VIEWPORT_SYNC_SIZE_TOLERANCE
747
+ && Math.abs(Number(dimensions.height) - Number(viewport.height)) <= VIEWPORT_SYNC_SIZE_TOLERANCE;
748
+ },
749
+
750
+ requestViewportSyncAfterRejectedFrame() {
751
+ const now = Date.now();
752
+ if (now - this._lastFrameRejectSyncAt < FRAME_REJECT_SYNC_COOLDOWN_MS) return;
753
+ this._lastFrameRejectSyncAt = now;
754
+ this.queueViewportSync(true);
755
+ },
756
+
757
+ clearRenderedFrameIfViewportChanged() {
758
+ const viewport = this.currentViewportSize();
759
+ if (!this.frameSrc || !this._lastFrameDimensions || !viewport) return;
760
+ if (this.frameMatchesViewport(this._lastFrameDimensions, viewport)) return;
761
+ this.resetRenderedFrame();
762
+ if (this.activeBrowserId) {
763
+ this._surfaceSwitching = true;
764
+ this.switchingBrowserId = this.activeBrowserId;
765
+ }
766
},
767
768
cancelFrameRender() {
@@ -681,6 +772,8 @@ const model = {
772
this._frameRenderHandle = null;
773
this._frameRenderCancel = null;
774
this._pendingFrameSrc = "";
775
+ this._pendingFrameOptions = null;
776
+ this._frameRenderSequence += 1;
777
},
778
779
async command(command, extra = {}) {
@@ -865,11 +958,16 @@ const model = {
958
if (snapshot.state) {
959
this.applyActiveFrameState(snapshot.state);
960
}
868
- this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`);
869
- if (this.sameBrowserId(this.switchingBrowserId, snapshotId || this.activeBrowserId)) {
870
- this.switchingBrowserId = null;
871
- }
872
- this._surfaceSwitching = false;
961
+ const frameBrowserId = snapshotId || this.activeBrowserId;
962
+ this.queueFrameRender(`data:${snapshot.mime || "image/jpeg"};base64,${snapshot.image}`, {
963
+ browserId: frameBrowserId,
964
+ onAccepted: () => {
965
+ if (this.sameBrowserId(this.switchingBrowserId, frameBrowserId)) {
966
+ this.switchingBrowserId = null;
967
+ }
968
+ this._surfaceSwitching = false;
969
+ },
970
+ });
971
},
972
973
isSwitchingBrowser() {
@@ -1371,6 +1469,7 @@ const model = {
1469
},
1470
1471
queueViewportSync(force = false) {
1472
+ this.clearRenderedFrameIfViewportChanged();
1473
if (this._viewportSyncTimer) {
1474
globalThis.clearTimeout(this._viewportSyncTimer);
1475
}
tests/test_browser_agent_regressions.py
+58
-14
@@ -110,6 +110,21 @@ import plugins._browser.tools.browser as browser_tool_module
110
import plugins._browser.api.ws_browser as ws_browser_module
111
112
113
+SMALL_JPEG_10X10 = (
114
+ "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsL"
115
+ "DBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/"
116
+ "2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy"
117
+ "MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAKAAoDASIAAhEBAxEB/8QAFQAB"
118
+ "AAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhADE"
119
+ "AAAAKf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAEFAqf/xAAUEQEAAAAAAAA"
120
+ "AAAAAAAAAAAAA/9oACAEDAQE/ASP/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECA"
121
+ "QE/ASP/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAY/Aqf/xAAUEAEAAAAAAA"
122
+ "AAAAAAAAAAAAAA/9oACAEBAAE/ISf/2gAMAwEAAgADAAAAEP/EABQRAQAAAAAAAAA"
123
+ "AAAAAAAAAAP/aAAgBAwEBPxAk/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEB"
124
+ "PxAk/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxAn/9k="
125
+)
126
+
127
+
128
def test_browser_url_normalization_matches_address_bar_hosts():
129
assert normalize_url("localhost:3000") == "http://localhost:3000/"
130
assert normalize_url("127.0.0.1:8000/path") == "http://127.0.0.1:8000/path"
@@ -365,7 +380,8 @@ def test_browser_canvas_startup_waits_for_raw_viewport_settle():
380
encoding="utf-8"
381
)
382
368
- assert "const CANVAS_VIEWPORT_SETTLE_MS = 260;" in js
383
+ assert "const CANVAS_VIEWPORT_SETTLE_MS = 520;" in js
384
+ assert "const SURFACE_VIEWPORT_STABLE_FRAMES = 4;" in js
385
assert "surfaceViewportMeasurement()" in js
386
assert "rawWidth" in js
387
assert "rawHeight" in js
@@ -454,6 +470,9 @@ def test_browser_viewer_uses_cdp_screencast_transport():
470
assert "viewport_width: initialViewport?.width" in browser_store
471
assert "viewport_height: initialViewport?.height" in browser_store
472
assert "this.frameState = data.state || null" not in browser_store
473
+ assert "function loadFrameDimensions(src)" in browser_store
474
+ assert "frameMatchesViewport(dimensions = null, viewport = null)" in browser_store
475
+ assert "requestViewportSyncAfterRejectedFrame()" in browser_store
476
assert "overflow: hidden;" in main_html
477
assert "object-fit: fill;" in main_html
478
assert "image-rendering: auto;" in main_html
@@ -504,19 +523,7 @@ def test_browser_runtime_and_content_helper_expose_annotation_target():
523
524
@pytest.mark.anyio
525
async def test_browser_screencast_acknowledges_and_drops_stale_frames():
507
- first_image = (
508
- "/9j/4AAQSkZJRgABAQAAAQABAAD/2wBDAAgGBgcGBQgHBwcJCQgKDBQNDAsL"
509
- "DBkSEw8UHRofHh0aHBwgJC4nICIsIxwcKDcpLDAxNDQ0Hyc5PTgyPC4zNDL/"
510
- "2wBDAQkJCQwLDBgNDRgyIRwhMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIyMjIy"
511
- "MjIyMjIyMjIyMjIyMjIyMjIyMjIyMjL/wAARCAAKAAoDASIAAhEBAxEB/8QAFQAB"
512
- "AAAAAAAAAAAAAAAAAAAACf/EABQQAQAAAAAAAAAAAAAAAAAAAAD/2gAMAwEAAhADE"
513
- "AAAAKf/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAEFAqf/xAAUEQEAAAAAAAA"
514
- "AAAAAAAAAAAAA/9oACAEDAQE/ASP/xAAUEQEAAAAAAAAAAAAAAAAAAAAA/9oACAECA"
515
- "QE/ASP/xAAUEAEAAAAAAAAAAAAAAAAAAAAA/9oACAEBAAY/Aqf/xAAUEAEAAAAAAA"
516
- "AAAAAAAAAAAAAA/9oACAEBAAE/ISf/2gAMAwEAAgADAAAAEP/EABQRAQAAAAAAAAA"
517
- "AAAAAAAAAAP/aAAgBAwEBPxAk/8QAFBEBAAAAAAAAAAAAAAAAAAAAAP/aAAgBAgEB"
518
- "PxAk/8QAFBABAAAAAAAAAAAAAAAAAAAAAP/aAAgBAQABPxAn/9k="
519
- )
526
+ first_image = SMALL_JPEG_10X10
527
528
class FakeSession:
529
def __init__(self):
@@ -572,6 +579,43 @@ async def test_browser_screencast_acknowledges_and_drops_stale_frames():
579
assert session.detached is True
580
581
582
+@pytest.mark.anyio
583
+async def test_browser_screencast_keeps_rejecting_wrong_viewport_frames():
584
+ class FakeSession:
585
+ def __init__(self):
586
+ self.handlers = {}
587
+ self.sent = []
588
+
589
+ def on(self, event, handler):
590
+ self.handlers[event] = handler
591
+
592
+ async def send(self, method, params=None):
593
+ self.sent.append((method, params or {}))
594
+
595
+ async def detach(self):
596
+ pass
597
+
598
+ session = FakeSession()
599
+ screencast = _BrowserScreencast(
600
+ stream_id="stream",
601
+ browser_id=7,
602
+ session=session,
603
+ mime="image/jpeg",
604
+ )
605
+
606
+ await screencast.start(quality=92, every_nth_frame=1, viewport={"width": 1118, "height": 662})
607
+ for session_id in range(1, 14):
608
+ session.handlers["Page.screencastFrame"](
609
+ {"data": SMALL_JPEG_10X10, "metadata": {}, "sessionId": session_id}
610
+ )
611
+ await asyncio.sleep(0)
612
+
613
+ assert await screencast.pop_frame() is None
614
+ assert ("Page.screencastFrameAck", {"sessionId": 13}) in session.sent
615
+
616
+ await screencast.stop()
617
+
618
+
619
def test_browser_docker_installs_full_chromium_to_persistent_cache():
620
script = (
621
PROJECT_ROOT / "docker" / "run" / "fs" / "ins" / "install_playwright.sh"