Improve screencast performance: lower defaults, fps cap, drop mousemove flood
Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
+7
-3
@@ -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
|
||||||
|
|||||||
@@ -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
@@ -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,
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
Reference in New Issue
Block a user