fix improve remote browser poor stream performance

Signed-off-by: RonniSkansing <rskansing@gmail.com>
This commit is contained in:
RonniSkansing
2026-07-24 01:13:37 +02:00
parent 07771d8f5d
commit 7cd8aff3ee
2 changed files with 79 additions and 27 deletions
+39 -11
View File
@@ -963,7 +963,9 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
}
// Shared across tab switches; all feed into the write loop below.
frameCh := make(chan *proto.PageScreencastFrame, 8)
// Buffered to 1 so only the newest frame is ever queued: a slow client drops
// intermediate frames instead of rendering a growing backlog of stale ones.
frameCh := make(chan *proto.PageScreencastFrame, 1)
urlCh := make(chan string, 4)
switchCh := make(chan *rod.Page, 1)
// notifyCh routes pre-encoded JSON from background goroutines to the write
@@ -1026,9 +1028,22 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
streamPage := p.Context(pageCtx)
wait := streamPage.EachEvent(
func(e *proto.PageScreencastFrame) (stop bool) {
// Ack immediately so Chrome keeps producing at full rate regardless
// of how fast the client drains. Then keep only the newest frame so a
// slow client never renders a backlog of stale frames.
proto.PageScreencastFrameAck{SessionID: e.SessionID}.Call(streamPage) //nolint:errcheck
select {
case frameCh <- e:
default:
// Buffer full: discard the queued stale frame, replace with this one.
select {
case <-frameCh:
default:
}
select {
case frameCh <- e:
default:
}
}
return
},
@@ -1291,6 +1306,11 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
}
}()
// lastFrameW/H track the last CSS viewport dimensions sent to the client so the
// frame_meta control message is only emitted when they change. -1 forces an
// initial send on the first frame.
lastFrameW, lastFrameH := float64(-1), float64(-1)
for {
select {
case <-page.GetContext().Done():
@@ -1333,22 +1353,30 @@ func (m *RemoteBrowserController) StreamLiveSession(g *gin.Context) {
if !ok {
return
}
proto.PageScreencastFrameAck{SessionID: frame.SessionID}.Call(getActivePage()) //nolint:errcheck
// Ack is done by the screencast producer, so this write path never gates
// Chrome's frame production.
var frameW, frameH float64
if frame.Metadata != nil {
frameW = frame.Metadata.DeviceWidth
frameH = frame.Metadata.DeviceHeight
}
payload, err := json.Marshal(map[string]any{
"type": "frame",
"data": base64.StdEncoding.EncodeToString(frame.Data),
"width": frameW,
"height": frameH,
})
if err != nil {
continue
// The CSS viewport dimensions (used client side for input coordinate
// mapping) change rarely, so send them as a small control message only on
// change instead of wrapping every frame. The JPEG itself goes as a raw
// binary message with no base64 or JSON overhead.
if frameW != lastFrameW || frameH != lastFrameH {
lastFrameW, lastFrameH = frameW, frameH
if payload, err := json.Marshal(map[string]any{
"type": "frame_meta",
"width": frameW,
"height": frameH,
}); err == nil {
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
return
}
}
}
if err := conn.WriteMessage(websocket.TextMessage, payload); err != nil {
if err := conn.WriteMessage(websocket.BinaryMessage, frame.Data); err != nil {
return
}
}
@@ -149,13 +149,17 @@
};
ws.onmessage = (ev) => {
// Binary messages are raw JPEG screencast frames.
if (typeof ev.data !== 'string') {
renderFrame(ev.data);
return;
}
try {
const msg = JSON.parse(typeof ev.data === 'string' ? ev.data : new TextDecoder().decode(ev.data));
if (msg.type === 'frame') {
const msg = JSON.parse(ev.data);
if (msg.type === 'frame_meta') {
// CSS viewport dimensions used to scale input coordinates.
if (msg.width) remoteWidth = msg.width;
if (msg.height) remoteHeight = msg.height;
renderFrame(msg.data);
frameCount++;
} else if (msg.type === 'url') {
currentURL = msg.value;
if (!urlBarFocused) urlBarValue = msg.value;
@@ -186,18 +190,38 @@
};
}
function renderFrame(base64jpeg) {
if (!canvas) return;
const img = new Image();
img.onload = () => {
const ctx = canvas.getContext('2d');
// Only resize when dimensions change — resizing always clears the canvas
// and flushes the GPU texture even when the value is identical.
if (canvas.width !== img.naturalWidth) canvas.width = img.naturalWidth;
if (canvas.height !== img.naturalHeight) canvas.height = img.naturalHeight;
ctx.drawImage(img, 0, 0);
};
img.src = 'data:image/jpeg;base64,' + base64jpeg;
// Most recent undecoded frame waiting for the decoder, plus a flag so only one
// decode runs at a time. createImageBitmap decodes off the main thread, so
// under load we drop intermediate frames and always draw the newest one.
let pendingFrame = null;
let decoding = false;
function renderFrame(buf) {
pendingFrame = buf;
if (decoding) return;
decoding = true;
(async () => {
while (pendingFrame) {
const bytes = pendingFrame;
pendingFrame = null;
try {
const bmp = await createImageBitmap(new Blob([bytes], { type: 'image/jpeg' }));
if (canvas) {
const ctx = canvas.getContext('2d');
// Only resize when dimensions change — resizing always clears the
// canvas and flushes the GPU texture even when the value is identical.
if (canvas.width !== bmp.width) canvas.width = bmp.width;
if (canvas.height !== bmp.height) canvas.height = bmp.height;
ctx.drawImage(bmp, 0, 0);
}
bmp.close();
frameCount++;
} catch {
// ignore decode errors (partial or corrupt frame)
}
}
decoding = false;
})();
}
function closeStream() {