From a409f35e6ccc94ee8b41259dbb898b907f0f3a8a Mon Sep 17 00:00:00 2001 From: dekun Date: Sat, 27 Jun 2026 11:09:16 +0800 Subject: [PATCH] Improve screencast performance: lower defaults, fps cap, drop mousemove flood Co-authored-by: Cursor --- .env.example | 10 +++++-- DEPLOY.md | 23 ++++++++++++++ app/browser_manager.py | 31 ++++++++++++++----- app/input_handler.py | 3 -- app/security.py | 14 +++++++-- docker-compose.yml | 8 +++-- static/viewer.js | 68 ++++++++++++++++++++++++++++-------------- 7 files changed, 116 insertions(+), 41 deletions(-) diff --git a/.env.example b/.env.example index e20338a..a7f375a 100644 --- a/.env.example +++ b/.env.example @@ -4,6 +4,10 @@ APP_PORT=32450 # 应用配置 MAX_SESSIONS=1 SESSION_IDLE_TIMEOUT=1800 -VIEWPORT_WIDTH=1280 -VIEWPORT_HEIGHT=720 -SCREENCAST_QUALITY=80 + +# 画面性能(卡顿时可继续调低) +VIEWPORT_WIDTH=1024 +VIEWPORT_HEIGHT=576 +SCREENCAST_QUALITY=55 +SCREENCAST_MAX_FPS=15 +SCREENCAST_EVERY_NTH_FRAME=2 diff --git a/DEPLOY.md b/DEPLOY.md index bb7cc08..ff85aeb 100644 --- a/DEPLOY.md +++ b/DEPLOY.md @@ -206,6 +206,29 @@ docker compose down docker compose up -d ``` +### 画面卡顿 + +这是 JPEG 画面流方案的正常现象,可调整 `/opt/cloud-browser/.env`: + +```env +VIEWPORT_WIDTH=960 +VIEWPORT_HEIGHT=540 +SCREENCAST_QUALITY=45 +SCREENCAST_MAX_FPS=12 +SCREENCAST_EVERY_NTH_FRAME=3 +``` + +修改后执行: + +```bash +cd /opt/cloud-browser +docker compose up -d --build +``` + +浏览页右上角会显示当前 fps,便于判断网络是否跟得上。 + +其他原因:VPS 内存不足、跨境带宽延迟、Nginx 反代未开 WebSocket。 + ### 页面黑屏 ```bash diff --git a/app/browser_manager.py b/app/browser_manager.py index 69cd78a..e0302a9 100644 --- a/app/browser_manager.py +++ b/app/browser_manager.py @@ -7,7 +7,13 @@ from typing import Any, Optional from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright -from app.security import get_idle_timeout, get_screencast_quality, get_viewport_size +from app.security import ( + get_idle_timeout, + get_screencast_max_fps, + get_screencast_nth_frame, + get_screencast_quality, + get_viewport_size, +) @dataclass @@ -25,8 +31,9 @@ class BrowserSession: screencast_task: Optional[asyncio.Task] = None idle_task: Optional[asyncio.Task] = None closed: bool = False - viewport_width: int = 1280 - viewport_height: int = 720 + viewport_width: int = 1024 + viewport_height: int = 576 + last_frame_sent_at: float = 0.0 class BrowserManager: @@ -53,7 +60,6 @@ class BrowserManager: "--no-sandbox", "--disable-setuid-sandbox", "--disable-dev-shm-usage", - "--disable-gpu", ], ) context = await browser.new_context( @@ -143,7 +149,7 @@ class BrowserManager: return session.url def subscribe(self, session: BrowserSession) -> asyncio.Queue: - queue: asyncio.Queue = asyncio.Queue(maxsize=8) + queue: asyncio.Queue = asyncio.Queue(maxsize=2) session.subscribers.add(queue) return queue @@ -173,10 +179,13 @@ class BrowserManager: await self._broadcast(session, {"type": "url_update", "url": session.url}) async def _run_screencast(self, session: BrowserSession, quality: int) -> None: + nth_frame = get_screencast_nth_frame() + min_frame_interval = 1.0 / get_screencast_max_fps() + async def on_screencast_frame(params: dict) -> None: if session.closed: return - data = params.get("data", "") + session_id = params.get("sessionId") try: await session.cdp.send( @@ -184,10 +193,18 @@ class BrowserManager: ) except Exception: return + + now = time.time() + if now - session.last_frame_sent_at < min_frame_interval: + return + + data = params.get("data", "") try: frame_bytes = base64.b64decode(data) except Exception: return + + session.last_frame_sent_at = now await self._broadcast( session, { @@ -211,7 +228,7 @@ class BrowserManager: "quality": quality, "maxWidth": session.viewport_width, "maxHeight": session.viewport_height, - "everyNthFrame": 1, + "everyNthFrame": nth_frame, }, ) diff --git a/app/input_handler.py b/app/input_handler.py index a9d0fe7..27dc27d 100644 --- a/app/input_handler.py +++ b/app/input_handler.py @@ -29,9 +29,6 @@ async def handle_input( return {"type": "ack", "action": action} if action == "mousemove": - x = float(payload.get("x", 0)) - y = float(payload.get("y", 0)) - await page.mouse.move(x, y) return None if action == "wheel": diff --git a/app/security.py b/app/security.py index 2553c27..111d6b8 100644 --- a/app/security.py +++ b/app/security.py @@ -83,11 +83,19 @@ def get_idle_timeout() -> int: def get_viewport_size() -> tuple[int, int]: - width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1280"))) - height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "720"))) + width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1024"))) + height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "576"))) return width, height def get_screencast_quality() -> int: - quality = int(os.getenv("SCREENCAST_QUALITY", "80")) + quality = int(os.getenv("SCREENCAST_QUALITY", "55")) return min(100, max(10, quality)) + + +def get_screencast_nth_frame() -> int: + return max(1, int(os.getenv("SCREENCAST_EVERY_NTH_FRAME", "2"))) + + +def get_screencast_max_fps() -> int: + return min(30, max(5, int(os.getenv("SCREENCAST_MAX_FPS", "15")))) diff --git a/docker-compose.yml b/docker-compose.yml index 70dd453..06c7b4c 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -9,9 +9,11 @@ services: - DATA_DIR=/app/data - MAX_SESSIONS=${MAX_SESSIONS:-1} - SESSION_IDLE_TIMEOUT=${SESSION_IDLE_TIMEOUT:-1800} - - VIEWPORT_WIDTH=${VIEWPORT_WIDTH:-1280} - - VIEWPORT_HEIGHT=${VIEWPORT_HEIGHT:-720} - - SCREENCAST_QUALITY=${SCREENCAST_QUALITY:-80} + - VIEWPORT_WIDTH=${VIEWPORT_WIDTH:-1024} + - VIEWPORT_HEIGHT=${VIEWPORT_HEIGHT:-576} + - SCREENCAST_QUALITY=${SCREENCAST_QUALITY:-55} + - SCREENCAST_MAX_FPS=${SCREENCAST_MAX_FPS:-15} + - SCREENCAST_EVERY_NTH_FRAME=${SCREENCAST_EVERY_NTH_FRAME:-2} volumes: - ./data:/app/data shm_size: "1gb" diff --git a/static/viewer.js b/static/viewer.js index 8dc0b08..4a2f5ad 100644 --- a/static/viewer.js +++ b/static/viewer.js @@ -8,11 +8,16 @@ const overlayMsg = document.getElementById("overlay-msg"); let ws = null; - let viewportWidth = 1280; - let viewportHeight = 720; + let viewportWidth = 1024; + let viewportHeight = 576; let scaleX = 1; let scaleY = 1; let pingTimer = null; + let pendingFrame = null; + let frameScheduled = false; + let frameCount = 0; + let lastFpsTime = performance.now(); + const frameImage = new Image(); const wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:"; const wsUrl = `${wsProtocol}//${window.location.host}/ws/${sessionId}`; @@ -39,21 +44,45 @@ } } - function drawFrame(blob) { - const img = new Image(); - const url = URL.createObjectURL(blob); - img.onload = () => { - if (canvas.width !== img.width || canvas.height !== img.height) { - canvas.width = img.width; - canvas.height = img.height; - viewportWidth = img.width; - viewportHeight = img.height; - updateScale(); + function drawDecodedFrame() { + if (canvas.width !== frameImage.width || canvas.height !== frameImage.height) { + canvas.width = frameImage.width; + canvas.height = frameImage.height; + viewportWidth = frameImage.width; + viewportHeight = frameImage.height; + updateScale(); + } + ctx.drawImage(frameImage, 0, 0); + frameCount += 1; + const now = performance.now(); + if (now - lastFpsTime >= 2000) { + const fps = Math.round((frameCount * 1000) / (now - lastFpsTime)); + setStatus(`已连接 · ${fps} fps`); + frameCount = 0; + lastFpsTime = now; + } + } + + function scheduleFrame(arrayBuffer) { + pendingFrame = arrayBuffer; + if (frameScheduled) { + return; + } + frameScheduled = true; + requestAnimationFrame(() => { + frameScheduled = false; + if (!pendingFrame) { + return; } - ctx.drawImage(img, 0, 0); - URL.revokeObjectURL(url); - }; - img.src = url; + const blob = new Blob([pendingFrame], { type: "image/jpeg" }); + pendingFrame = null; + const url = URL.createObjectURL(blob); + frameImage.onload = () => { + URL.revokeObjectURL(url); + drawDecodedFrame(); + }; + frameImage.src = url; + }); } function updateScale() { @@ -73,7 +102,7 @@ ws.onmessage = (event) => { if (event.data instanceof ArrayBuffer) { - drawFrame(new Blob([event.data], { type: "image/jpeg" })); + scheduleFrame(event.data); return; } @@ -124,11 +153,6 @@ send({ action: "wheel", deltaX: e.deltaX, deltaY: e.deltaY }); }, { passive: false }); - canvas.addEventListener("mousemove", (e) => { - const { x, y } = mapCoords(e.clientX, e.clientY); - send({ action: "mousemove", x, y }); - }); - const specialKeys = new Set([ "Enter", "Backspace", "Delete", "Tab", "Escape", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",