Improve screencast performance: lower defaults, fps cap, drop mousemove flood

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
dekun
2026-06-27 11:09:16 +08:00
parent b9ee546bc1
commit a409f35e6c
7 changed files with 116 additions and 41 deletions
+7 -3
View File
@@ -4,6 +4,10 @@ APP_PORT=32450
# 应用配置 # 应用配置
MAX_SESSIONS=1 MAX_SESSIONS=1
SESSION_IDLE_TIMEOUT=1800 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
+23
View File
@@ -206,6 +206,29 @@ docker compose down
docker compose up -d 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 ```bash
+24 -7
View File
@@ -7,7 +7,13 @@ from typing import Any, Optional
from playwright.async_api import Browser, BrowserContext, Page, Playwright, async_playwright 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 @dataclass
@@ -25,8 +31,9 @@ class BrowserSession:
screencast_task: Optional[asyncio.Task] = None screencast_task: Optional[asyncio.Task] = None
idle_task: Optional[asyncio.Task] = None idle_task: Optional[asyncio.Task] = None
closed: bool = False closed: bool = False
viewport_width: int = 1280 viewport_width: int = 1024
viewport_height: int = 720 viewport_height: int = 576
last_frame_sent_at: float = 0.0
class BrowserManager: class BrowserManager:
@@ -53,7 +60,6 @@ class BrowserManager:
"--no-sandbox", "--no-sandbox",
"--disable-setuid-sandbox", "--disable-setuid-sandbox",
"--disable-dev-shm-usage", "--disable-dev-shm-usage",
"--disable-gpu",
], ],
) )
context = await browser.new_context( context = await browser.new_context(
@@ -143,7 +149,7 @@ class BrowserManager:
return session.url return session.url
def subscribe(self, session: BrowserSession) -> asyncio.Queue: 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) session.subscribers.add(queue)
return queue return queue
@@ -173,10 +179,13 @@ class BrowserManager:
await self._broadcast(session, {"type": "url_update", "url": session.url}) await self._broadcast(session, {"type": "url_update", "url": session.url})
async def _run_screencast(self, session: BrowserSession, quality: int) -> None: 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: async def on_screencast_frame(params: dict) -> None:
if session.closed: if session.closed:
return return
data = params.get("data", "")
session_id = params.get("sessionId") session_id = params.get("sessionId")
try: try:
await session.cdp.send( await session.cdp.send(
@@ -184,10 +193,18 @@ class BrowserManager:
) )
except Exception: except Exception:
return return
now = time.time()
if now - session.last_frame_sent_at < min_frame_interval:
return
data = params.get("data", "")
try: try:
frame_bytes = base64.b64decode(data) frame_bytes = base64.b64decode(data)
except Exception: except Exception:
return return
session.last_frame_sent_at = now
await self._broadcast( await self._broadcast(
session, session,
{ {
@@ -211,7 +228,7 @@ class BrowserManager:
"quality": quality, "quality": quality,
"maxWidth": session.viewport_width, "maxWidth": session.viewport_width,
"maxHeight": session.viewport_height, "maxHeight": session.viewport_height,
"everyNthFrame": 1, "everyNthFrame": nth_frame,
}, },
) )
-3
View File
@@ -29,9 +29,6 @@ async def handle_input(
return {"type": "ack", "action": action} return {"type": "ack", "action": action}
if action == "mousemove": if action == "mousemove":
x = float(payload.get("x", 0))
y = float(payload.get("y", 0))
await page.mouse.move(x, y)
return None return None
if action == "wheel": if action == "wheel":
+11 -3
View File
@@ -83,11 +83,19 @@ def get_idle_timeout() -> int:
def get_viewport_size() -> tuple[int, int]: def get_viewport_size() -> tuple[int, int]:
width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1280"))) width = max(800, int(os.getenv("VIEWPORT_WIDTH", "1024")))
height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "720"))) height = max(600, int(os.getenv("VIEWPORT_HEIGHT", "576")))
return width, height return width, height
def get_screencast_quality() -> int: 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)) 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"))))
+5 -3
View File
@@ -9,9 +9,11 @@ services:
- DATA_DIR=/app/data - DATA_DIR=/app/data
- MAX_SESSIONS=${MAX_SESSIONS:-1} - MAX_SESSIONS=${MAX_SESSIONS:-1}
- SESSION_IDLE_TIMEOUT=${SESSION_IDLE_TIMEOUT:-1800} - SESSION_IDLE_TIMEOUT=${SESSION_IDLE_TIMEOUT:-1800}
- VIEWPORT_WIDTH=${VIEWPORT_WIDTH:-1280} - VIEWPORT_WIDTH=${VIEWPORT_WIDTH:-1024}
- VIEWPORT_HEIGHT=${VIEWPORT_HEIGHT:-720} - VIEWPORT_HEIGHT=${VIEWPORT_HEIGHT:-576}
- SCREENCAST_QUALITY=${SCREENCAST_QUALITY:-80} - SCREENCAST_QUALITY=${SCREENCAST_QUALITY:-55}
- SCREENCAST_MAX_FPS=${SCREENCAST_MAX_FPS:-15}
- SCREENCAST_EVERY_NTH_FRAME=${SCREENCAST_EVERY_NTH_FRAME:-2}
volumes: volumes:
- ./data:/app/data - ./data:/app/data
shm_size: "1gb" shm_size: "1gb"
+46 -22
View File
@@ -8,11 +8,16 @@
const overlayMsg = document.getElementById("overlay-msg"); const overlayMsg = document.getElementById("overlay-msg");
let ws = null; let ws = null;
let viewportWidth = 1280; let viewportWidth = 1024;
let viewportHeight = 720; let viewportHeight = 576;
let scaleX = 1; let scaleX = 1;
let scaleY = 1; let scaleY = 1;
let pingTimer = null; 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 wsProtocol = window.location.protocol === "https:" ? "wss:" : "ws:";
const wsUrl = `${wsProtocol}//${window.location.host}/ws/${sessionId}`; const wsUrl = `${wsProtocol}//${window.location.host}/ws/${sessionId}`;
@@ -39,21 +44,45 @@
} }
} }
function drawFrame(blob) { function drawDecodedFrame() {
const img = new Image(); if (canvas.width !== frameImage.width || canvas.height !== frameImage.height) {
const url = URL.createObjectURL(blob); canvas.width = frameImage.width;
img.onload = () => { canvas.height = frameImage.height;
if (canvas.width !== img.width || canvas.height !== img.height) { viewportWidth = frameImage.width;
canvas.width = img.width; viewportHeight = frameImage.height;
canvas.height = img.height; updateScale();
viewportWidth = img.width; }
viewportHeight = img.height; ctx.drawImage(frameImage, 0, 0);
updateScale(); 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); const blob = new Blob([pendingFrame], { type: "image/jpeg" });
URL.revokeObjectURL(url); pendingFrame = null;
}; const url = URL.createObjectURL(blob);
img.src = url; frameImage.onload = () => {
URL.revokeObjectURL(url);
drawDecodedFrame();
};
frameImage.src = url;
});
} }
function updateScale() { function updateScale() {
@@ -73,7 +102,7 @@
ws.onmessage = (event) => { ws.onmessage = (event) => {
if (event.data instanceof ArrayBuffer) { if (event.data instanceof ArrayBuffer) {
drawFrame(new Blob([event.data], { type: "image/jpeg" })); scheduleFrame(event.data);
return; return;
} }
@@ -124,11 +153,6 @@
send({ action: "wheel", deltaX: e.deltaX, deltaY: e.deltaY }); send({ action: "wheel", deltaX: e.deltaX, deltaY: e.deltaY });
}, { passive: false }); }, { passive: false });
canvas.addEventListener("mousemove", (e) => {
const { x, y } = mapCoords(e.clientX, e.clientY);
send({ action: "mousemove", x, y });
});
const specialKeys = new Set([ const specialKeys = new Set([
"Enter", "Backspace", "Delete", "Tab", "Escape", "Enter", "Backspace", "Delete", "Tab", "Escape",
"ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight",