From 7cd8aff3ee616e0e095b3f70f9f4bc146873778c Mon Sep 17 00:00:00 2001 From: RonniSkansing Date: Fri, 24 Jul 2026 01:13:37 +0200 Subject: [PATCH] fix improve remote browser poor stream performance Signed-off-by: RonniSkansing --- backend/controller/remoteBrowser.go | 50 +++++++++++++---- .../remote-browser/RemoteBrowserStream.svelte | 56 +++++++++++++------ 2 files changed, 79 insertions(+), 27 deletions(-) diff --git a/backend/controller/remoteBrowser.go b/backend/controller/remoteBrowser.go index 718961d..ed40afb 100644 --- a/backend/controller/remoteBrowser.go +++ b/backend/controller/remoteBrowser.go @@ -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 } } diff --git a/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte b/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte index 598c371..c142b18 100644 --- a/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte +++ b/frontend/src/lib/components/remote-browser/RemoteBrowserStream.svelte @@ -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() {