Metrics & adaptive quality¶
pdum.rfb measures every session and can adapt encoding quality to the link in real
time. This page is the end-to-end view — it spans the Python server and the
browser client, which is why it lives on its own rather than split across the two
guides. For the per-language API details see the
Python guide and the
JavaScript guide.
The loop¶
browser server (per RfbSession)
─────── ───────────────────────
decode a frame ── ack{seq, decode_queue_size, displayed} ──► measure RTT (send→displayed),
│ decode-queue depth, fps, bitrate
│ │
│ AdaptiveQualityController.update()
│ lowers bitrate → fps → in-flight (recovers when healthy)
│ │
◄── set_quality{bitrate, fps} (new targets) ────────────────────┤
◄── stats{rtt_ms, fps_sent, bitrate_bps, ...} (server truth) ───┘ (opt-in: stats_interval)
│
fold both into `Stats` ──► onStats(stats) ──► your UI
The client is the sensor (it reports its decode-queue depth and acks every displayed
frame); the server is the controller (it measures and decides); and the two control
messages close the loop back to your UI. Nothing here is required — a plain
serve() streams happily without any of it — but turning it on gives you a live,
honest picture of the link and lets the encoder ride congestion down and back up.
Server: turn it on¶
import pdum.rfb as rfb
display = await rfb.serve(
1280, 720,
adaptive=True, # react to congestion: bitrate → fps → in-flight
stats_interval=1.0, # push server-truth metrics to each client every 1 s
)
adaptive=Trueenables the three-lever controller (see the Python guide). It rebuilds the encoder and emitsset_qualityas it reacts.stats_interval=1.0opts into the periodic server→clientstatsmessage. Without it the browser only knows its own decode side; with it, it also sees the server's authoritative RTT, fps, bitrate, and encode time.
Server metrics are also available over HTTP for dashboards/scraping (no browser needed):
curl http://127.0.0.1:8765/metrics # one object per active session
curl http://127.0.0.1:8765/streams/<name>/metrics # per stream, with a hub
Both return the same SessionMetrics.snapshot() shape the stats push is built
from.
Client: the Stats object¶
The view delivers a Stats to your onStats callback (and exposes the latest as
view.stats). Its fields split into two groups:
| Field | Source | Notes |
|---|---|---|
transport |
local | "image" \| "webcodecs" \| "none" |
framesDisplayed, framesDropped |
local | what the client actually drew |
lastDisplayedSeq |
local | newest displayed frame |
decodeQueueSize |
local | the congestion signal it reports back |
serverRttMs |
server | send→displayed round trip, server-measured |
serverFpsSent, serverBitrateBps |
server | what the server is actually emitting |
serverEncodeMs, serverDropped |
server | encode cost / server-side drops |
targetBitrate, targetFps |
server | the adaptive controller's current targets |
The server* / target* fields are undefined until the server pushes them —
i.e. only when it was started with stats_interval (and/or adaptive). Always
guard on presence.
Showing it in the UI¶
onStats fires on every displayed frame (and on each set_quality / stats), so
it's all you need to drive a live HUD. A self-contained, framework-free example:
import { RemoteFramebufferView, type Stats } from "@habemus-papadum/rfb-widgets";
const hud = document.getElementById("hud")!;
const mbps = (bps?: number) => (bps === undefined ? "—" : `${(bps / 1e6).toFixed(1)} Mbps`);
const ms = (v?: number) => (v === undefined ? "—" : `${v.toFixed(0)} ms`);
const n = (v?: number) => (v === undefined ? "—" : v.toFixed(1));
function renderHud(s: Stats): void {
const rows: [string, string][] = [
["transport", s.transport],
["displayed", `${s.framesDisplayed} (dropped ${s.framesDropped})`],
["decode queue", String(s.decodeQueueSize)],
// server-truth — only present with serve(stats_interval=…)
["rtt", ms(s.serverRttMs)],
["server fps", n(s.serverFpsSent)],
["server bitrate", mbps(s.serverBitrateBps)],
["encode", ms(s.serverEncodeMs)],
// adaptive targets — present with serve(adaptive=True)
["target bitrate", mbps(s.targetBitrate)],
["target fps", n(s.targetFps)],
];
hud.innerHTML = rows.map(([k, v]) => `<div><span>${k}</span><b>${v}</b></div>`).join("");
}
new RemoteFramebufferView(document.getElementById("stage")!, {
url: "ws://localhost:8765",
onStats: renderHud,
// optionally throttle your own redraws if you prefer a fixed cadence:
// onStats: (s) => { latest = s; } // then render from a setInterval / rAF
});
onStats can fire at the frame rate, so if your HUD is expensive, stash the latest
Stats and repaint on a timer or requestAnimationFrame instead of every callback.
A note on fps numbers: serverFpsSent is how fast the server is emitting,
which in the push model is whatever cadence your publish loop runs at (not a fixed
target). targetFps is the adaptive controller's current ceiling — it drops below
serverFpsSent only when the controller has eased the rate under congestion.
See it live¶
The bundled demo renders exactly this HUD from onStats. Run the server with
adaptation + stats on and open the demo:
uv run python -m pdum.rfb.server --pattern bouncing_box --adaptive --stats-interval 1.0
cd widgets && pnpm dev # http://localhost:5173
The HUD shows — for the server* rows until the first stats push arrives (~1 s),
then fills in; drop the --stats-interval flag and those rows stay — while the
local rows keep updating — a quick way to see exactly which numbers are local vs.
server-truth.