API Reference¶
Remote Frame Buffer.
A transport-neutral remote framebuffer: Python produces frames, encodes them (image or H.264), streams them over WebSocket, and a browser decodes them to a canvas while sending pointer/key/resize events back.
The public API is push-based: start a server with :func:serve, then
publish() frames to the returned :class:Display from your own loop and drain
input with :meth:Display.poll_events.
The PyAV-dependent H.264 symbols (H264CpuEncoder, NvencCpuEncoder,
h264_available, nvenc_cpu_available) are loaded lazily via :pep:562 so base
(image-only) installs without the optional av dependency can still
import pdum.rfb.
AdaptiveQualityController
dataclass
¶
Map observed metrics to a target quality with hysteresis + cooldown.
Source code in src/pdum/rfb/adaptive.py
update(metrics, *, now)
¶
Return a new target when a change is warranted, else None.
Source code in src/pdum/rfb/adaptive.py
AuthContext
dataclass
¶
Everything the auth hook may inspect about a connecting client.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str | None
|
The credential from the client's |
None
|
headers
|
Mapping[str, str] | None
|
Handshake request headers (e.g. |
None
|
cookies
|
Mapping[str, str] | None
|
Parsed request cookies, when the transport exposes them (e.g. the ASGI adapter) — the natural home for a same-origin session/OAuth cookie. |
None
|
path
|
str | None
|
Request path including query string, when available. |
None
|
query
|
Mapping[str, str] | None
|
Parsed query parameters, when available. |
None
|
remote
|
tuple[str, int] | None
|
|
None
|
hello
|
dict | None
|
The full decoded |
None
|
stream
|
str | None
|
Name of the stream (named :class: |
None
|
Source code in src/pdum/rfb/auth.py
BackendSelection
dataclass
¶
The encoder/transport the server chose for a connection.
Source code in src/pdum/rfb/protocol.py
Channel
¶
Bases: Protocol
The minimal duplex byte/text channel the session drives.
Source code in src/pdum/rfb/transport.py
__aiter__()
¶
ColorSpace
dataclass
¶
A small, explicit display-referred color descriptor for a frame/stream.
Mirrors the WebCodecs VideoColorSpace fields the browser consumes directly
(primaries gamut, transfer function, matrix RGB-vs-YUV coupling) plus a
full_range flag and a bit_depth for the HDR future. Two presets ship as
first-class — :data:SRGB (the implicit default) and :data:DISPLAY_P3 (Apple
wide-gamut SDR, 8-bit); bt2020/pq/hlg are expressible (HDR is designed-for)
but not yet wired through a 10-bit pipeline.
The library tags color; it does not convert. The upstream renderer is responsible for producing pixels already in the declared space.
Source code in src/pdum/rfb/types.py
Display
¶
A single shared framebuffer that one or more browsers attach to.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
width
|
int
|
Initial framebuffer size. Updated automatically whenever you publish a differently-shaped frame. |
required |
height
|
int
|
Initial framebuffer size. Updated automatically whenever you publish a differently-shaped frame. |
required |
fps
|
int
|
Advisory frame rate (used as the encoder's IDR cadence / metrics target); the actual cadence is whatever your publish loop does. |
30
|
record_events
|
bool
|
Also accumulate raw events in :attr: |
False
|
event_log
|
str | Path | None
|
Optional path; received events are appended as JSON lines. |
None
|
event_queue_size
|
int
|
Bound on the un-polled event backlog; the oldest events are dropped
when a publisher never calls :meth: |
4096
|
own_frames
|
bool
|
Opt in to server-owned frames. By default :meth: |
False
|
resize_policy
|
str
|
|
'publisher'
|
max_render_dimension
|
int | None
|
Cap on either dimension of a |
None
|
resize_debounce
|
float
|
Seconds a |
0.12
|
clock
|
Callable[[], float] | None
|
Monotonic clock returning seconds; injectable for deterministic tests. |
None
|
Source code in src/pdum/rfb/display.py
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 | |
client_count
property
¶
Number of currently connected viewers.
color
property
¶
Color descriptor of the latest published frame, or None (sRGB).
pixel_ratio
property
¶
Render-side DPR of the latest published frame (1.0 before the first publish).
port
property
¶
The bound TCP port (useful when serving with port=0).
server
property
¶
The owning :class:~pdum.rfb.server.Server hub, if this is a stream of one.
Lets the convenience display = await serve(...) path reach the hub to add
more streams: display.server.add_stream("camera_b", 640, 480). None
for a bare Display constructed directly.
target_ratio
property
¶
The client DPR that accompanies :attr:target_size (1.0 until one arrives).
target_size
property
¶
Latest client-requested render size under resize_policy="match_client" (debounced,
clamped, even dims), or None before any viewport arrives / in "publisher" mode.
Read it in your render loop to follow the viewer::
w, h = display.target_size or (display.width, display.height)
display.publish(render(state, w, h), pixel_ratio=display.target_ratio)
ws_url
property
¶
Browser-reachable WebSocket URL for this stream (e.g. ws://host:port/name).
Raises if the server is not bound yet (call await rfb.serve(...) first). A
wildcard bind host (0.0.0.0/::) is reported as 127.0.0.1.
aclose()
async
¶
Stop the server, disconnect viewers, and release encoder resources.
Source code in src/pdum/rfb/display.py
events()
async
¶
Async-iterate queued events (alternative to :meth:poll_events).
Yields :class:~pdum.rfb.types.InputEvent\ s and, under
serve(adaptive=True), :class:~pdum.rfb.types.DownscaleHint\ s. Use this or
:meth:poll_events — both drain the same queue.
Source code in src/pdum/rfb/display.py
poll_events()
¶
Drain and return everything queued since the last poll.
The list is ordinarily :class:~pdum.rfb.types.InputEvent\ s (input from all
connected viewers). With serve(adaptive=True) it may also carry a
:class:~pdum.rfb.types.DownscaleHint — a server-driven resolution suggestion —
interleaved in arrival order; tell them apart with isinstance.
Source code in src/pdum/rfb/display.py
publish(frame, *, pixel_ratio=None, color=None)
¶
Make frame the latest frame and wake every connected viewer.
Synchronous and non-blocking. frame may be:
- a contiguous host
uint8array —(H, W, 3)rgb24or(H, W, 4)rgba8; - a CUDA tensor exposing
__cuda_array_interface__(e.g. CuPy) of shape(H, W, 3|4)— published as a zero-copycudaframe (for NV12, or other frameworks, build aRawFramevia :func:pdum.rfb.gpu.cuda_frame); - an MLX (Apple Metal) array of shape
(H, W, 3|4)— published as ametalframe; the VideoToolbox encoder converts RGB(A)→NV12 on the GPU (for pre-converted NV12, use :func:pdum.rfb.metal.metal_frame); - a ready :class:
~pdum.rfb.types.RawFrame(anymemory).
Latest-frame-wins: a viewer that is behind simply skips intermediate frames.
Ownership. By default publish() borrows your buffer — it stores a bare
reference and reads the pixels asynchronously, on each viewer's encode worker
thread. The borrow window runs from here until every viewer has finished encoding
the frame, and it is widest under still_after (the resting frame is re-read
~still_after seconds later for the lossless still). So in borrow mode, publish a
fresh buffer each call, or do not mutate a published buffer until it is encoded.
Construct the Display (or call :func:~pdum.rfb.serve) with own_frames=True
to instead have the server copy each frame into a recycled buffer, after which you
may reuse/mutate your own buffer immediately (cpu/cuda only; metal raises).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pixel_ratio
|
float | None
|
Render-side DPR for this frame (device px per logical px). |
None
|
color
|
Any
|
Color descriptor for this frame — a :class: |
None
|
Source code in src/pdum/rfb/display.py
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | |
record(path, *, fps=None, bitrate=None, crf=None, preset='veryfast')
¶
Record the published-frame stream to a real MP4 file (server-side tap).
Returns a started :class:~pdum.rfb.recording.Recording — an async context
manager / handle with :meth:~pdum.rfb.recording.Recording.stop. It taps the
same latest-frame stream the viewer sessions pull from, so it is independent
of any connected browser and works fully headless (demos, CI artifacts,
golden-frame capture). Frames are H.264-encoded (libx264) and muxed to
AVCC-in-MP4 via a separate PyAV path — this is the one place AVCC/MP4 is
correct; it never touches the Annex-B WebSocket payload.
The real, monotonic per-frame timestamps are honored, so a variable-cadence
publisher (sparse still_after scenes included) records with correct frame
timing. Call it on the event-loop thread; it starts a background encode task.
::
async with display.record("out.mp4"):
for _ in range(300):
display.publish(render()); await asyncio.sleep(1 / 30)
# file finalized on exit — or use rec = display.record(...); await rec.stop()
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Output |
required |
fps
|
int | None
|
Advisory frame rate for the encoder's rate control (default: the display's
|
None
|
bitrate
|
int | None
|
Target bitrate in bits/s (average). Mutually exclusive with |
None
|
crf
|
int | None
|
Constant Rate Factor (0–51, lower = better) for constant-quality encoding. |
None
|
preset
|
str
|
libx264 speed/quality preset (default |
'veryfast'
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If PyAV (the |
Source code in src/pdum/rfb/display.py
widget(*, batteries=True, base_path=None, host=None, **chrome)
¶
Return an anywidget viewer bound to this stream (needs the [anywidget] extra).
In a notebook: display.widget() renders the batteries viewer;
display.widget(batteries=False) the bare canvas. One widget = one Web Worker +
one WebSocket; the server multiplexes many streams on one port. For remote/HTTPS
notebooks, mount pdum.rfb.asgi.rfb_hub_endpoint same-origin and pass
base_path= (the widget then uses a same-origin wss:// URL). Extra keyword
args (e.g. show_toolbar=False) become widget traits.
Raises if the server is not bound yet.
Source code in src/pdum/rfb/display.py
DownscaleHint
dataclass
¶
A server-driven adaptive resolution hint delivered through poll_events().
In the push model the publisher owns the framebuffer size, so the adaptive
controller cannot resize the stream itself — its deepest congestion lever is to
suggest a smaller render resolution and let the render loop honor it. Enabled by
serve(adaptive=True), the controller emits one of these under sustained pressure
(bitrate, fps, and in-flight already at their floors) and again on recovery (with
scale climbing back toward 1.0); the :class:~pdum.rfb.display.Display fans
the aggregate across viewers (the most-congested viewer wins) into the same queue
:meth:~pdum.rfb.display.Display.poll_events drains, tagged as this distinct type so
it is easy to tell apart from an :class:InputEvent::
for ev in display.poll_events():
if isinstance(ev, rfb.DownscaleHint):
w, h = ev.width, ev.height # render/publish at this size
else:
state = update(state, ev.event) # ordinary input event
Honoring the hint is opt-in: publish a frame of the suggested size (the per-viewer
encoder rebuilds and the browser re-configure()s exactly as for any other
resize) or ignore it entirely — the stream keeps working either way.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scale
|
float
|
The recommended render scale relative to full resolution, in |
required |
width
|
int
|
A convenience suggested size — the display's initial (base) dimensions scaled
by |
required |
height
|
int
|
A convenience suggested size — the display's initial (base) dimensions scaled
by |
required |
received_us
|
int
|
Monotonic microseconds (relative to the display's start) when the hint was emitted. |
required |
Source code in src/pdum/rfb/types.py
EncodedPayload
dataclass
¶
A single encoded payload ready to be put on the wire.
One image is one payload (always a keyframe). One encoded video access unit
is one payload; keyframe marks IDR access units.
Source code in src/pdum/rfb/types.py
EncoderBackend
¶
Bases: Protocol
Turns raw frames into encoded payloads.
Source code in src/pdum/rfb/types.py
H264CpuEncoder
¶
Encode CPU rgb24 frames to H.264 Annex B access units.
Source code in src/pdum/rfb/encoders/h264_cpu.py
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | |
encode_still(frame)
¶
A settled-scene still for the video path: a forced IDR of the frame.
True lossless H.264 isn't practical over WebCodecs, so the still here is a
clean, self-contained intra (IDR) of the resting frame — it refreshes the
image and lets a client that dropped deltas during a flurry jump straight
to the latest. Re-encoding advances frame_index so the PTS stays
monotonic. For a pixel-exact settled image, use the image transport.
Source code in src/pdum/rfb/encoders/h264_cpu.py
ImageEncoder
¶
Encode CPU RGB/RGBA frames to JPEG, PNG or WebP.
Source code in src/pdum/rfb/encoders/image.py
encode_still(frame)
¶
Encode frame losslessly as PNG — the "still after settle" upgrade.
Independent of the streaming mode: once a scene settles, the resting
frame is re-sent pixel-exact so it is crisp even when the live stream is
lossy JPEG/WebP. Every image is already a keyframe, so the browser swaps it
in with no client-side changes.
Source code in src/pdum/rfb/encoders/image.py
InputEvent
dataclass
¶
A normalized user-input event delivered to the application.
Produced by :class:~pdum.rfb.display.Display as it fans connected clients'
input into a single stream the application drains with
:meth:~pdum.rfb.display.Display.poll_events.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client_id
|
str
|
Opaque per-connection identifier, so several viewers on one display can be told apart (e.g. for multi-user coordination). |
required |
principal
|
Any | None
|
Whatever the |
required |
event
|
EventDict
|
The raw normalized event dict ( |
required |
received_us
|
int
|
Monotonic microseconds (relative to the display's start) when the event was received. |
required |
Source code in src/pdum/rfb/types.py
NvencCpuEncoder
¶
Bases: H264CpuEncoder
Encode CPU rgb24 frames to H.264 Annex B on the GPU via NVENC.
Drop-in for :class:H264CpuEncoder (same constructor and EncoderBackend
interface); only the underlying codec context differs. Frames narrower than
:data:NVENC_MIN_WIDTH are rejected because NVENC cannot open below it.
Source code in src/pdum/rfb/encoders/nvenc_cpu.py
NvencGpuPyavEncoder
¶
Bases: H264CpuEncoder
Encode CUDA (or host, with upload) frames to H.264 Annex B via NVENC, zero-copy.
Drop-in for :class:~pdum.rfb.encoders.nvenc.NvencCpuEncoder (same
constructor / EncoderBackend interface); only the input handling differs.
Frames narrower than :data:~pdum.rfb.encoders.nvenc.NVENC_MIN_WIDTH are
rejected (NVENC cannot open below it), and dimensions must be even (NV12).
Source code in src/pdum/rfb/encoders/nvenc_gpu_pyav.py
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | |
QualityTarget
dataclass
¶
A requested change to the session's encoding quality.
scale is the recommended render-resolution scale in (0, 1] (1.0 = full
resolution). It is advisory to the publisher (surfaced as a
:class:~pdum.rfb.types.DownscaleHint), not applied by the session like the other
fields; defaults to 1.0 so existing callers are unaffected.
Source code in src/pdum/rfb/adaptive.py
RawFrame
dataclass
¶
A single raw frame produced by a :class:FrameSource.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
seq
|
int
|
Monotonically increasing frame sequence number. |
required |
width
|
int
|
Frame dimensions in pixels. |
required |
height
|
int
|
Frame dimensions in pixels. |
required |
timestamp_us
|
int
|
Capture/render timestamp in microseconds. |
required |
pixel_format
|
PixelFormat
|
Layout of |
required |
memory
|
MemoryKind
|
Where |
required |
data
|
Any
|
The pixel payload. For CPU frames this is a |
required |
pixel_ratio
|
float
|
Render-side device-pixels-per-logical-pixel of this frame (default |
1.0
|
color
|
dict | None
|
Optional color descriptor ( |
None
|
Source code in src/pdum/rfb/types.py
Recording
¶
Handle for an in-progress server-side MP4 recording.
Created (and started) by :meth:~pdum.rfb.display.Display.record. Use it as an async
context manager (auto-finalizes on exit) or call :meth:stop explicitly::
async with display.record("out.mp4"):
... # publish frames
# -- or --
rec = display.record("out.mp4")
...
await rec.stop() # flush the encoder, finalize the container
Attributes:
| Name | Type | Description |
|---|---|---|
frames_written |
Count of frames encoded into the file so far (useful for tests / progress). |
Source code in src/pdum/rfb/recording.py
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | |
stop()
async
¶
Stop recording and finalize the MP4 file (idempotent).
Signals the tap to unpark, awaits the encode task (which flushes the encoder and
closes the container in its finally), then re-raises any error the task hit.
Source code in src/pdum/rfb/recording.py
RfbSession
¶
Drive one client connection: encode + send frames, receive events.
Source code in src/pdum/rfb/session.py
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 | |
metrics_snapshot()
¶
Return a JSON-serializable snapshot of this session's metrics.
Source code in src/pdum/rfb/session.py
request_reconfigure(*, factory=None, selection=None, bitrate=None, fps=None)
¶
Queue a live encoder swap and/or quality change.
Applied before the next encode (never mid-encode). factory swaps the encoder
backend/transport; selection (a :class:~pdum.rfb.protocol.BackendSelection)
triggers a fresh config to the client; bitrate/fps retune quality.
Must be called on the event-loop thread (it only stores state).
Source code in src/pdum/rfb/session.py
run()
async
¶
Run the receive and encode loops until the connection closes.
Source code in src/pdum/rfb/session.py
Server
¶
A hub: one WebSocket listener fronting several named streams.
Each stream is an independent :class:~pdum.rfb.display.Display with its own
encoder config; a browser selects one by URL path (ws://host/<stream>),
and a connection with no path lands on the "default" stream. Streams are
discoverable over HTTP at GET /streams.
Build one with :func:serve_server, or use :func:serve for the common
single-default-stream case (it returns the default Display and keeps
display.server pointing back here so you can add_stream more).
This composes with everything else — multi-client fan-out, per-client
backpressure, the encoders, auth, "still after settle" — none of which changes;
a stream is just a Display plus its config, and routing is purely additive.
Source code in src/pdum/rfb/server.py
427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 | |
port
property
¶
The bound TCP port (the actual one when started with port=0).
streams
property
¶
The names of the registered streams.
aclose()
async
¶
Stop the listener and disconnect every viewer of every stream.
Source code in src/pdum/rfb/server.py
add_stream(name, width, height, *, fps=30, bitrate=12000000, max_inflight=2, has_h264=None, has_nvenc=None, gpu=False, adaptive=False, still_after=None, stats_interval=None, authenticate=None, record_events=False, event_log=None, event_queue_size=4096, own_frames=False, resize_policy='publisher', max_render_dimension=None, encode_pipeline_depth=0)
¶
Register a new named stream and return its :class:Display.
Streams are independent: each carries its own encoder config (one GPU, one
image; per-stream bitrate; per-stream authenticate). Safe to call before
or after :meth:start — clients reach it at ws://host/<name> either way.
Raises if name is already taken. own_frames / resize_policy /
max_render_dimension are forwarded to the stream's :class:Display.
Source code in src/pdum/rfb/server.py
process_request(connection, request)
¶
Answer the HTTP side-channel routes; return None to proceed with WS.
Global: GET /health, GET /streams, GET /streams/<name>/metrics.
For backward compatibility the single-stream routes (/metrics,
/recorded-events, /recorded-events/reset) act on the "default"
stream when one exists.
Source code in src/pdum/rfb/server.py
remove_stream(name)
¶
Remove a named stream, disconnecting its viewers — the inverse of :meth:add_stream.
No-op if name is absent. The stream's :class:Display is closed locally
(viewers dropped, waiters woken) but the shared listener keeps running for the
other streams; a later connection to name closes with 4404. Used by the
demo hub to reap idle per-client streams.
Source code in src/pdum/rfb/server.py
start()
async
¶
Start the shared listener in the background; returns self.
Source code in src/pdum/rfb/server.py
stream(name=DEFAULT_STREAM)
¶
SessionMetrics
dataclass
¶
Mutable accumulator of one session's performance counters.
Source code in src/pdum/rfb/metrics.py
snapshot(*, now)
¶
Return a JSON-serializable view including derived rates.
Source code in src/pdum/rfb/metrics.py
UnsupportedClient
¶
WebSocketTransport
¶
Adapt a websockets server connection to the :class:Channel surface.
A raw websockets connection already satisfies :class:Channel; this thin
wrapper exists as the documented seam (and one place to translate disconnect
semantics for non-websockets transports later).
Source code in src/pdum/rfb/transport.py
__getattr__(name)
¶
Lazily import optional / submodule-executing symbols (PEP 562).
server is imported lazily so python -m pdum.rfb.server does not warn
about double execution; the H.264 symbols are lazy so base installs without
the optional av dependency can still import pdum.rfb.
Source code in src/pdum/rfb/__init__.py
available_video_encoders()
¶
build_encoder(selection, *, width, height, fps=30, bitrate=12000000, video_encoder='h264_cpu', pipeline_depth=0, color=None)
¶
Build the encoder backend described by selection.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
selection
|
BackendSelection
|
The result of :func: |
required |
width
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
height
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
fps
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
bitrate
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
video_encoder
|
str
|
Which registered video encoder to use for the H.264 transport. |
'h264_cpu'
|
pipeline_depth
|
int
|
Encoder pipeline depth. |
0
|
color
|
dict | None
|
Optional stream color descriptor ( |
None
|
Source code in src/pdum/rfb/encoders/base.py
cuda_frame(array, *, pixel_format='auto', width=None, height=None, seq=0, timestamp_us=0)
¶
Wrap a device tensor as a CUDA :class:~pdum.rfb.types.RawFrame for publish().
pixel_format="auto" infers from shape: (H, W, 3) -> rgb24,
(H, W, 4) -> rgba8, 2-D (H+H//2, W) -> nv12. Pass an explicit
pixel_format (and height for ambiguous NV12) to override. The tensor
is referenced, not copied; keep it alive until the frame is encoded.
Source code in src/pdum/rfb/gpu.py
cuda_zerocopy_available()
cached
¶
True if the zero-copy CUDA→NVENC path is usable in this process (cached).
Checks, in order: CuPy importable; an NVENC-capable GPU/driver
(:func:pdum.rfb.encoders.nvenc_cpu.nvenc_cpu_available); and that PyAV can actually
encode a CUDA frame (PyAV ≥ 18 or a from-source build with the fix). The
self-test opens an NVENC session, so it runs at most once per process.
.. note::
Call :func:enable_cuda_context_sharing before any CuPy CUDA op for a
reliable result — if CuPy has already activated the primary context with
the default flags, the shared-context probe (and the encoder) will fail.
Source code in src/pdum/rfb/gpu.py
enable_cuda_context_sharing(device_id=0, *, sched=_FFMPEG_SCHED)
¶
Pre-set the device primary-context flags so CuPy and FFmpeg share one context.
Call this once, first thing in your program, before any CuPy/PyTorch CUDA
op. It sets the device's primary-context scheduling flags to what FFmpeg's
CUDA hwcontext wants (CU_CTX_SCHED_BLOCKING_SYNC); CuPy then retains that
same primary context, and the zero-copy encoder (primary_ctx=True) can
register CuPy's device pointers.
Returns True on success. Returns False (and changes nothing) if the
CUDA driver can't be loaded. If the primary context is already active with
different flags (e.g. CuPy already ran), the driver call may still succeed but
the flags will not take effect until the context is reset — so order matters.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device_id
|
int
|
CUDA device ordinal. |
0
|
sched
|
str
|
One of |
_FFMPEG_SCHED
|
Source code in src/pdum/rfb/gpu.py
h264_available()
¶
metal_frame(array, *, pixel_format='auto', width=None, height=None, seq=0, timestamp_us=0)
¶
Wrap an MLX (Metal) array as a memory="metal" :class:~pdum.rfb.types.RawFrame for
publish() — the Metal analog of :func:pdum.rfb.gpu.cuda_frame.
pixel_format="auto" infers from shape: (H, W, 3) → rgb24, (H, W, 4) → rgba8,
2-D (H+H//2, W) → nv12. Use this for a pre-converted NV12 array; a plain RGBA array
can be handed straight to display.publish() (it is recognized as a Metal frame). The array
is referenced, not copied; keep it alive (and evaluated) until it is encoded.
Source code in src/pdum/rfb/metal.py
mlx_available()
cached
¶
True if MLX (Apple Metal) is usable in this process (cached). macOS + mlx importable.
Source code in src/pdum/rfb/metal.py
nvenc_cpu_available()
¶
True if a usable NVENC H.264 encoder is present (cached).
Guards, in order: the OS must be one where NVENC exists (not macOS); PyAV
must expose h264_nvenc; and the encoder must actually open and encode a
frame on the GPU (which requires an NVENC-capable device and a working
driver). The result is cached for the lifetime of the process.
Source code in src/pdum/rfb/encoders/nvenc_cpu.py
nvenc_gpu_pyav_available()
¶
True if the zero-copy CUDA→NVENC path is usable (see :func:cuda_zerocopy_available).
pack_binary_message(header, payload)
¶
Pack a header dict and payload bytes into a single binary message.
The header is encoded as compact UTF-8 JSON (no spaces) prefixed by its
little-endian uint32 byte length.
Source code in src/pdum/rfb/protocol.py
register_video_encoder(name, factory)
¶
select_transport(client_supported, *, has_h264, has_nvenc=False, prefer_video=True, image_mode='jpeg')
¶
Choose the best backend given client capabilities and server encoders.
Policy (guide section 12): if the client supports WebCodecs/H.264, the
server prefers video and at least one H.264 encoder is available, pick
H.264 (NVENC is preferred over the CPU path when present). Otherwise fall
back to the best mutually-supported image format. has_nvenc is accepted
now so the NVENC backend can be slotted in later without touching callers.
Raises:
| Type | Description |
|---|---|
UnsupportedClient
|
If no mutually-supported transport exists. |
Source code in src/pdum/rfb/protocol.py
serve(width, height, *, host='127.0.0.1', port=8765, fps=30, bitrate=12000000, max_inflight=2, has_h264=None, has_nvenc=None, gpu=False, adaptive=False, still_after=None, stats_interval=None, authenticate=None, origins=None, record_events=False, event_log=None, event_queue_size=4096, own_frames=False, resize_policy='publisher', max_render_dimension=None, encode_pipeline_depth=0)
async
¶
Start the RFB WebSocket server in the background and return a :class:Display.
You own your loop: display = await serve(w, h, port=...) then call
display.publish(frame) whenever you like, and await display.aclose() to
shut down.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
width
|
int
|
Initial framebuffer size (a connecting client is configured to the display's current size; publish a different shape to resize). |
required |
height
|
int
|
Initial framebuffer size (a connecting client is configured to the display's current size; publish a different shape to resize). |
required |
has_h264
|
bool | None
|
|
None
|
has_nvenc
|
bool | None
|
|
None
|
gpu
|
bool
|
Opt in to GPU encode: the publisher pushes CUDA frames (CuPy/DLPack NV12
or rgb) and each viewer's H.264 encoder reads them directly, no host copy.
Prefers the PyAV-free NVENC SDK backend ( |
False
|
still_after
|
float | None
|
Opt in to "still after interaction settles": when no new frame is
published for |
None
|
adaptive
|
bool
|
Enable adaptive quality (bitrate → fps → in-flight, with recovery): the
encoder is rebuilt as the controller reacts to the client's decode-queue
depth and RTT. Pairs well with |
False
|
stats_interval
|
float | None
|
Opt in to a periodic server→client |
None
|
authenticate
|
Authenticator | None
|
Optional async hook (see :mod: |
None
|
origins
|
list[str | None] | None
|
Allowed |
None
|
own_frames
|
bool
|
Opt in to server-owned frames: |
False
|
resize_policy
|
str
|
Opt in to match-client resize: |
'publisher'
|
max_render_dimension
|
str
|
Opt in to match-client resize: |
'publisher'
|
encode_pipeline_depth
|
int
|
Encoder pipeline depth. |
0
|
Notes
This hosts a single "default" stream. Reach the hub behind it via
display.server to host several streams from the one port
(display.server.add_stream("camera_b", 640, 480)), or start with
:func:serve_server for a hub with no default stream. See
docs/multiple_streams.md.
Source code in src/pdum/rfb/server.py
638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 | |
serve_server(*, host='127.0.0.1', port=8765, origins=None, streams=None)
async
¶
Start a multi-stream hub and return the :class:Server.
Unlike :func:serve (one default stream, returns its Display), this returns
the hub itself with no default stream. Add streams with
server.add_stream(name, w, h, **config) — each returns its own Display to
publish into — and clients attach by URL path (ws://host/<name>). A client
with no path is rejected until a "default" stream exists.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
host
|
str
|
As for :func: |
'127.0.0.1'
|
port
|
str
|
As for :func: |
'127.0.0.1'
|
origins
|
str
|
As for :func: |
'127.0.0.1'
|
streams
|
list[dict[str, Any]] | None
|
Optional list of |
None
|
Examples:
>>> server = await serve_server(port=8765)
>>> cam = server.add_stream("camera", 1280, 720)
>>> depth = server.add_stream("depth", 640, 480, has_h264=False)
>>> cam.publish(render_camera()); depth.publish(render_depth())
>>> await server.aclose()
Source code in src/pdum/rfb/server.py
unpack_binary_message(buf)
¶
Inverse of :func:pack_binary_message.
Returns:
| Type | Description |
|---|---|
tuple[dict, bytes]
|
The decoded JSON header and the raw payload bytes. |
Source code in src/pdum/rfb/protocol.py
adaptive
¶
Adaptive quality control (guide section 10).
A small, pure controller that watches the client's decode-queue depth and
round-trip latency and decides a new target quality. It is deliberately
transport- and encoder-agnostic: it only emits a :class:QualityTarget; the
session decides how to apply it (rebuild the H.264 encoder at the new bitrate,
tighten the in-flight ceiling).
Four knobs, applied in order under congestion:
- bitrate — the primary lever; reduce when congested, recover when healthy;
- fps — once bitrate is at the floor and the client is still behind, lower the target frame rate (the encoder is rebuilt at the new rate, lightening both encode cost and bandwidth);
- max in-flight — the latency lever; once bitrate and fps are at their floors, tighten the in-flight ceiling so latest-frame-wins drops more aggressively;
- resolution scale — the deepest lever, engaged only under sustained pressure
(bitrate, fps, and in-flight already floored). Unlike the others the session cannot
apply it — the publisher owns the framebuffer size — so it surfaces the target
:attr:
QualityTarget.scaleand the :class:~pdum.rfb.display.Displayfans a :class:~pdum.rfb.types.DownscaleHintout throughpoll_events()for the render loop to honor (publish a smaller frame; the encoder rebuild + keyframe on resize is the well-trodden path). See :class:~pdum.rfb.types.DownscaleHint.
Recovery walks back up when healthy. A cooldown prevents thrashing (each bitrate / fps / resolution change costs a keyframe).
AdaptiveQualityController
dataclass
¶
Map observed metrics to a target quality with hysteresis + cooldown.
Source code in src/pdum/rfb/adaptive.py
update(metrics, *, now)
¶
Return a new target when a change is warranted, else None.
Source code in src/pdum/rfb/adaptive.py
QualityTarget
dataclass
¶
A requested change to the session's encoding quality.
scale is the recommended render-resolution scale in (0, 1] (1.0 = full
resolution). It is advisory to the publisher (surfaced as a
:class:~pdum.rfb.types.DownscaleHint), not applied by the session like the other
fields; defaults to 1.0 so existing callers are unaffected.
Source code in src/pdum/rfb/adaptive.py
asgi
¶
ASGI / Starlette front-end: mount the framebuffer inside an existing app.
Opt-in (pip install habemus-papadum-rfb[asgi]) and additive: a second
front-end over the exact same :class:~pdum.rfb.display.Display /
:class:~pdum.rfb.session.RfbSession core as :func:pdum.rfb.serve. The standalone
serve() path (with its zero-extra-deps websockets listener) is unchanged —
this just lets you reach the same machinery through Starlette/FastAPI when you want
same-origin hosting: shared TLS, routing, and the app's session/OAuth cookie
(the auth hook receives AuthContext.cookies / .headers).
Because the ASGI server owns the event loop, the usage shape is: create your
Display (or :class:~pdum.rfb.server.Server hub) at app startup, run your publish
loop as a background task (e.g. from a lifespan handler), and mount one of the
endpoints below. This module only adds the WebSocket endpoint that drives one
RfbSession per connection.
Example (Starlette)::
import asyncio, contextlib, pdum.rfb as rfb
from pdum.rfb.asgi import rfb_endpoint
from starlette.applications import Starlette
from starlette.routing import WebSocketRoute
display = rfb.Display(1280, 720)
@contextlib.asynccontextmanager
async def lifespan(app):
async def publish_loop():
while True:
display.publish(render())
await asyncio.sleep(1 / 30)
task = asyncio.create_task(publish_loop())
yield
task.cancel()
app = Starlette(lifespan=lifespan, routes=[
WebSocketRoute("/rfb", rfb_endpoint(display, authenticate=my_cookie_auth)),
])
For several streams, mount :func:rfb_hub_endpoint on a path that captures a
{stream} parameter.
rfb_endpoint(display, *, name=DEFAULT_STREAM, has_h264=None, has_nvenc=None, gpu=False, bitrate=12000000, fps=None, max_inflight=2, adaptive=False, still_after=None, stats_interval=None, authenticate=None)
¶
Return a Starlette WebSocket endpoint that streams one :class:Display.
Mount it on any path::
app.add_websocket_route("/rfb", rfb_endpoint(display, authenticate=auth))
The keyword config mirrors :func:pdum.rfb.serve (encoder/transport selection,
gpu, adaptive, still_after, per-endpoint authenticate). fps
defaults to the display's. The authenticate hook receives an
:class:~pdum.rfb.auth.AuthContext carrying the request cookies / headers
so it can reuse the host app's same-origin session.
Source code in src/pdum/rfb/asgi.py
rfb_hub_endpoint(server, *, param='stream')
¶
Return a Starlette endpoint routing to a :class:Server hub's streams.
Mount it on a path that captures the stream name as a path parameter::
app.add_websocket_route("/rfb/{stream}", rfb_hub_endpoint(server))
The connection is routed to server's stream of that name (an unknown stream
closes with application code 4404); a request without the parameter uses the
"default" stream. The hub and its per-stream config (including per-stream
authenticate) are reused exactly as for the standalone listener.
Source code in src/pdum/rfb/asgi.py
auth
¶
Pluggable authentication seam (deliberately thin).
The library ships only the hook signature and the context/identity types — it
never depends on a JWT/JWKS library and has no opinion on how you authenticate.
You pass an authenticate callable to :func:pdum.rfb.serve; it is invoked once
per connection and returns an application-defined principal (any object) to
accept, or None to reject.
In v1 the credential arrives in the client's hello message
(AuthContext.token) because a browser WebSocket cannot set request headers.
The context also carries the handshake headers / path / query so a
future same-site-cookie or ASGI transport can feed the same hook without an API
change (at which point the hello token simply becomes optional).
Example — verify a Google OAuth ID token (your code; needs e.g. google-auth)::
from google.oauth2 import id_token
from google.auth.transport import requests as g_requests
ALLOWED = {"alice@example.com", "bob@example.com"}
_req = g_requests.Request()
async def authenticate(ctx):
if not ctx.token:
return None
try:
claims = id_token.verify_oauth2_token(ctx.token, _req, audience=CLIENT_ID)
except ValueError:
return None
email = claims.get("email")
return claims if email in ALLOWED else None
display = await rfb.serve(1280, 720, authenticate=authenticate)
AuthContext
dataclass
¶
Everything the auth hook may inspect about a connecting client.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
token
|
str | None
|
The credential from the client's |
None
|
headers
|
Mapping[str, str] | None
|
Handshake request headers (e.g. |
None
|
cookies
|
Mapping[str, str] | None
|
Parsed request cookies, when the transport exposes them (e.g. the ASGI adapter) — the natural home for a same-origin session/OAuth cookie. |
None
|
path
|
str | None
|
Request path including query string, when available. |
None
|
query
|
Mapping[str, str] | None
|
Parsed query parameters, when available. |
None
|
remote
|
tuple[str, int] | None
|
|
None
|
hello
|
dict | None
|
The full decoded |
None
|
stream
|
str | None
|
Name of the stream (named :class: |
None
|
Source code in src/pdum/rfb/auth.py
benchmark
¶
Offline encoder benchmark: image vs CPU H.264 vs GPU NVENC vs Apple VideoToolbox, fully headless.
Encodes a deterministic synthetic pattern and reports, per configuration:
- encode time (mean and p95, milliseconds per frame),
- payload size (mean bytes per frame),
- the bitrate that size implies at a target frame rate,
- quality as PSNR in dB, measured by decoding the output back (Pillow for images, PyAV for H.264) and comparing to the source — so quality is real, not assumed.
Run it directly::
uv run python -m pdum.rfb.benchmark --frames 120 --pattern gradient \
--sizes 640x480,1280x720 --h264-bitrate 2M,8M
This has no network and no browser; it is the quickest way to characterize the software encoders.
benchmark_nvenc_gpu_pdum(*, bitrate=8000000, frames=60, width=640, height=480, fps=30, pattern='gradient')
¶
NVIDIA Video Codec SDK encoder (habemus-papadum-nvenc / pdum.nvenc), no PyAV.
Frames are converted RGB→NV12 on the GPU and encoded straight from device
memory (one intra-GPU copy into NVENC's input surface). Comparable to
:func:benchmark_nvenc_gpu_pyav, but via NVIDIA's NvEncoderCuda instead of
PyAV's h264_nvenc — and so works without PyAV>=18. See
docs/proposals/completed/nvenc_sdk_evaluation.md.
Source code in src/pdum/rfb/benchmark.py
305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 | |
benchmark_nvenc_gpu_pyav(*, bitrate=8000000, frames=60, width=640, height=480, fps=30, pattern='gradient')
¶
Zero-copy CUDA→NVENC: frames pre-uploaded to the GPU, encoded with no host copy.
Mirrors :func:benchmark_nvenc but the per-frame timing covers the on-GPU
RGB→NV12 conversion + the zero-copy encode (the realistic "everything on GPU"
cost), so the row is directly comparable to the host nvenc row (whose cost
includes the CPU rgb→yuv reformat + the PCIe upload).
Source code in src/pdum/rfb/benchmark.py
benchmark_vtenc(*, bitrate=8000000, frames=60, width=640, height=480, fps=30, pattern='gradient', use_mlx=None)
¶
Apple VideoToolbox H.264 (macOS) — the counterpart of the NVENC GPU rows.
When MLX is available (use_mlx None auto-detects), the RGB→NV12 conversion
runs on the GPU — the serve(gpu=True) path — and the timed region covers that
GPU convert and the VideoToolbox encode, so the vtenc-gpu row is directly
comparable to the NVENC GPU rows. Without MLX the conversion is on the CPU
(vtenc-cpu). Decoded back with PyAV for real PSNR. See docs/metal_videotoolbox.md.
Source code in src/pdum/rfb/benchmark.py
cli
¶
pdum-rfb command-line entry point.
Commands (all optional — install with pip install habemus-papadum-rfb[cli] or [demo]):
pdum-rfb doctor— probe this box and show, as a table, which encode paths work (image, CPU H.264, host NVENC, zero-copy CUDA→NVENC, NVENC SDK, and — on macOS/Apple Silicon — Apple VideoToolbox + MLX) and which one to prefer.pdum-rfb benchmark— measure per-frame encode latency / size / PSNR for every available path (a Rich-rendered wrapper over :mod:pdum.rfb.benchmark).pdum-rfb demo— the self-contained web app ([demo]extra).pdum-rfb jupyter-demo/marimo-demo— copy the bundled "renderer + viewer in one kernel" notebook to a working dir and launch JupyterLab / marimo on it ([demo]extra).
The module imports cleanly without Typer/Rich; the console-script entry point then prints an install hint instead of crashing.
benchmark(sizes=typer.Option('1280x720,1920x1080', help='comma-separated WxH'), frames=typer.Option(120, help='frames per configuration'), fps=typer.Option(30, help='target frame rate'), bitrate=typer.Option('8M', help='H.264/NVENC target bitrate, e.g. 8M'), pattern=typer.Option('gradient'), jpeg_quality=typer.Option(80, help='JPEG quality for the image row'), image=typer.Option(True, help='include the image (JPEG) path'))
¶
Benchmark every available encode path on this box (latency, size, PSNR).
Source code in src/pdum/rfb/cli.py
253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 | |
demo(width=typer.Option(1280, help='framebuffer width (even)'), height=typer.Option(720, help='framebuffer height (even)'), port=typer.Option(0, help='HTTP/WebSocket server port (0 = pick a free one)'), fps=typer.Option(30, help='publish frame rate'), bitrate=typer.Option('8M', help='initial H.264/NVENC bitrate, e.g. 8M'), host=typer.Option('127.0.0.1', help='bind host (default localhost-only)'), verbose=typer.Option(False, '--verbose', '-v', help='DEBUG-level server logging'), open_browser=typer.Option(True, '--open/--no-open', help='open the browser at the demo URL'), dev=typer.Option(False, '--dev', help='live-reload agentic mode: Vite HMR (TS) + uvicorn reload (Python); needs repo + Node'), smoke=typer.Option(False, help='headless self-test: every backend + REST control, no browser'))
¶
Interactive web demo: one process serves the app + controls; drive it in the browser.
The control plane (scene / backend / quality / parameters) lives in the browser and rides REST; Python only serves the app and logs. Ships prebuilt — run it with uvx::
uvx --from 'habemus-papadum-rfb[demo]' pdum-rfb demo
For pair-debugging with an agent, --dev runs the SPA under Vite (instant TS HMR) and
the API under uvicorn reload=True (Python auto-restart) so edits to either side are
picked up live; the browser opens on the Vite URL and proxies REST + WS to Python.
Source code in src/pdum/rfb/cli.py
doctor()
¶
Probe this box and report which encode paths work.
Source code in src/pdum/rfb/cli.py
jupyter_demo(dir=typer.Option('', '--dir', help='working dir for the notebook copy (default: a temp dir)'), open_browser=typer.Option(True, '--open/--no-open', help='open the browser (JupyterLab default)'))
¶
Launch JupyterLab on the bundled 'renderer + viewer in one kernel' demo notebook.
Copies jupyter-demo.ipynb to a working dir and starts JupyterLab there::
uvx --from 'habemus-papadum-rfb[demo]' pdum-rfb jupyter-demo
The viewer connects to a WebSocket opened by the kernel — fine on a local notebook; in a hosted environment that port must be browser-reachable (see docs/notebook.md).
Source code in src/pdum/rfb/cli.py
marimo_demo(dir=typer.Option('', '--dir', help='working dir for the notebook copy (default: a temp dir)'), open_browser=typer.Option(True, '--open/--no-open', help='open the browser (marimo default)'))
¶
Launch marimo on the bundled 'renderer + viewer in one process' demo notebook.
Same wiring as the Jupyter demo, in marimo's reactive editor::
uvx --from 'habemus-papadum-rfb[demo]' pdum-rfb marimo-demo
Source code in src/pdum/rfb/cli.py
demo_server
¶
The pdum-rfb demo web app: a Starlette control plane over a framebuffer hub.
One pdum-rfb demo process serves a self-contained web app — the prebuilt SPA
(built under static/demo/ via pnpm -C widgets build:demo; a build artifact, not
committed — the release CI builds it into the wheel), a small REST control plane, and the framebuffer
WebSocket(s) — all on one origin. There is no Node, no Vite, and no terminal UI: the
browser holds both the remote-framebuffer viewer and the controls (scene / encode
backend / quality / the richer parameters), and drives the server with REST calls. The
Python side only serves the app and logs lifecycle to stdout.
Architecture
- A :class:
~pdum.rfb.server.Serverhub owns the named streams, but itswebsocketslistener is never started — every connection is driven through Starlette via :func:~pdum.rfb.asgi.rfb_hub_endpoint, so REST + static + WS share one uvicorn origin. - A :class:
DemoStreamManagerowns, per stream, the :class:_DemoState(which scene is live) and the publish task (:func:_render_loop). - Streams model the two multi-client modes. The shared
"default"stream is coupled — many viewers see the same frames and any client's control affects them all (fan-out). A client can also mint a private stream (its own scene / backend / and the structural parameters chosen at birth), so two tabs can compare backends side by side. Private streams are reaped a short grace period after their last viewer leaves and are capped to bound resources.
Live vs structural parameters
scene / backend / bitrate / fps / resolution / color are cheap to
change on a running stream (the existing live-switch / resize / per-frame-tag paths), so
they are editable anytime. The structural parameters (adaptive, still_after,
stats_interval, encode_pipeline_depth, resize_policy) are fixed at
add_stream time — you explore them by creating a private stream.
A headless :func:smoke drives the real ASGI app in-process (Starlette TestClient):
capabilities, every backend switched over REST on one socket, a multi-viewer fan-out
check, and a private-stream create → connect → destroy cycle. It is the CI-grade proof.
DemoStreamManager
¶
Owns the demo's streams: their scenes, publish tasks, and private-stream lifecycle.
Source code in src/pdum/rfb/demo_server.py
405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 | |
create(name, *, width, height, adaptive=False, still_after=None, stats_interval=None, encode_pipeline_depth=0, resize_policy='publisher', max_render_dimension=None, private=False)
¶
Register a stream on the hub, seed its scene, and start its publish task.
Source code in src/pdum/rfb/demo_server.py
create_private(body)
¶
Mint a new private stream from a create-request body; returns its name.
Raises :class:RuntimeError when the private-stream cap is reached.
Source code in src/pdum/rfb/demo_server.py
destroy(name)
async
¶
Cancel a private stream's publish task and remove it from the hub.
Source code in src/pdum/rfb/demo_server.py
available_backends()
¶
(id, label) for every backend usable on this box (the greenlit subset).
backend_catalog()
¶
Every encode backend the demo knows, each tagged available/why-not for greying-out.
id is what :meth:~pdum.rfb.server._StreamHost.switch_backend accepts:
image:<mode> or a registered video-encoder name. The image modes are always
available; the video backends are gated by their runtime probe, and unavailable ones
carry a short reason so the panel can explain the grey.
Source code in src/pdum/rfb/demo_server.py
build_demo_app(*, width=1280, height=720, fps=30, bitrate='8M', stats_interval=1.0, static_dir=STATIC_DEMO_DIR, private_cap=8)
¶
Build the demo's Starlette app: REST control + framebuffer WS + the static SPA.
The hub's websockets listener is intentionally never started — connections are
driven through :func:~pdum.rfb.asgi.rfb_hub_endpoint, so everything shares one
uvicorn origin. If static_dir is missing (SPA not built yet) a placeholder page is
served so the control plane is still exercisable.
Source code in src/pdum/rfb/demo_server.py
617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 758 759 760 761 762 763 764 765 766 767 768 769 770 771 772 773 774 775 776 777 778 779 780 781 782 783 784 785 786 787 788 789 790 791 792 793 794 795 796 797 | |
capabilities()
¶
The payload behind GET /demo/capabilities — drives greying-out + control render.
Source code in src/pdum/rfb/demo_server.py
make_dev_app()
¶
App factory for pdum-rfb demo --dev — uvicorn reload=True re-imports this on every
Python change, so config is read from env (it survives the reload subprocess). API-only: in
dev the SPA is served by Vite (with HMR), which proxies REST + WS back to this process.
Source code in src/pdum/rfb/demo_server.py
run_demo(*, width=1280, height=720, host='127.0.0.1', port=0, fps=30, bitrate='8M', verbose=False, open_browser=True, dev=False)
¶
Serve the demo web app (blocking). Localhost-only by default; port=0 picks a free one.
open_browser launches the browser at the URL once it's up; dev runs the live-reload
agentic mode (Vite HMR + uvicorn reload) — see :func:_run_dev.
Source code in src/pdum/rfb/demo_server.py
scene_catalog()
¶
Every built-in scene, tagged available/why-not for greying-out.
Source code in src/pdum/rfb/demo_server.py
smoke(*, width=320, height=240, fps=30, verbose=True)
¶
Drive the real ASGI demo in-process: capabilities, every backend over REST on one socket, a 2-viewer fan-out check, and a private-stream create→connect→destroy cycle.
Uses Starlette's TestClient (no uvicorn, no real port, no browser). Returns a
result dict; raises AssertionError on any failure. This is the CI-grade proof.
Source code in src/pdum/rfb/demo_server.py
1077 1078 1079 1080 1081 1082 1083 1084 1085 1086 1087 1088 1089 1090 1091 1092 1093 1094 1095 1096 1097 1098 1099 1100 1101 1102 1103 1104 1105 1106 1107 1108 1109 1110 1111 1112 1113 1114 1115 1116 1117 1118 1119 1120 1121 1122 1123 1124 1125 1126 1127 1128 1129 1130 1131 1132 1133 1134 1135 1136 1137 1138 1139 1140 1141 1142 1143 1144 1145 1146 1147 1148 1149 1150 1151 1152 1153 1154 1155 1156 1157 1158 1159 1160 1161 1162 1163 1164 1165 1166 1167 1168 1169 1170 1171 1172 1173 1174 1175 1176 1177 1178 1179 1180 1181 1182 1183 1184 1185 1186 | |
demos
¶
Built-in demo scenes for pdum-rfb demo (the interactive harness).
Each :class:Demo is a small, self-contained scene the harness can publish into a shared
:class:~pdum.rfb.display.Display. A demo is a factory (make()) so selecting it
starts fresh; the resulting instance exposes:
frame(seq, t, width, height) -> np.ndarray— the RGB(A) frame to publish, and- optionally
on_event(event) -> None— to consume browser input (pointer/key/wheel).
Adding a demo is a few lines: write a make returning an object with frame (and
maybe on_event), then append a :class:Demo to :data:DEMOS. Demos whose
available() returns False (missing platform/deps) are hidden by the harness.
Demo
dataclass
¶
A selectable demo scene.
Source code in src/pdum/rfb/demos.py
available_demos()
¶
display
¶
The push-model :class:Display: you publish frames, viewers attach to watch.
The application owns its loop and pushes the latest frame into a Display;
the library fans that frame out to every connected browser, each driven by its own
:class:~pdum.rfb.session.RfbSession and encoder (so each viewer gets a keyframe
on attach and independent latest-frame-wins backpressure). Input events from all
viewers funnel into one stream the application drains with :meth:poll_events.
display = await rfb.serve(1280, 720, port=8765) # background WS server + handle
state = init()
while running:
for ev in display.poll_events(): # ev.client_id, ev.principal, ev.event
state = update(state, ev)
display.publish(render(state)) # sync, non-blocking, latest-wins
await asyncio.sleep(1 / 30) # or ad-hoc / every 60 s — you own the cadence
await display.aclose()
publish() must be called on the event-loop thread (it wakes feeds via
asyncio.Event). Publishing a differently-shaped array transparently rebuilds
each viewer's encoder and forces a keyframe; keep pixel_format constant.
Display
¶
A single shared framebuffer that one or more browsers attach to.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
width
|
int
|
Initial framebuffer size. Updated automatically whenever you publish a differently-shaped frame. |
required |
height
|
int
|
Initial framebuffer size. Updated automatically whenever you publish a differently-shaped frame. |
required |
fps
|
int
|
Advisory frame rate (used as the encoder's IDR cadence / metrics target); the actual cadence is whatever your publish loop does. |
30
|
record_events
|
bool
|
Also accumulate raw events in :attr: |
False
|
event_log
|
str | Path | None
|
Optional path; received events are appended as JSON lines. |
None
|
event_queue_size
|
int
|
Bound on the un-polled event backlog; the oldest events are dropped
when a publisher never calls :meth: |
4096
|
own_frames
|
bool
|
Opt in to server-owned frames. By default :meth: |
False
|
resize_policy
|
str
|
|
'publisher'
|
max_render_dimension
|
int | None
|
Cap on either dimension of a |
None
|
resize_debounce
|
float
|
Seconds a |
0.12
|
clock
|
Callable[[], float] | None
|
Monotonic clock returning seconds; injectable for deterministic tests. |
None
|
Source code in src/pdum/rfb/display.py
83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 636 637 638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 | |
client_count
property
¶
Number of currently connected viewers.
color
property
¶
Color descriptor of the latest published frame, or None (sRGB).
pixel_ratio
property
¶
Render-side DPR of the latest published frame (1.0 before the first publish).
port
property
¶
The bound TCP port (useful when serving with port=0).
server
property
¶
The owning :class:~pdum.rfb.server.Server hub, if this is a stream of one.
Lets the convenience display = await serve(...) path reach the hub to add
more streams: display.server.add_stream("camera_b", 640, 480). None
for a bare Display constructed directly.
target_ratio
property
¶
The client DPR that accompanies :attr:target_size (1.0 until one arrives).
target_size
property
¶
Latest client-requested render size under resize_policy="match_client" (debounced,
clamped, even dims), or None before any viewport arrives / in "publisher" mode.
Read it in your render loop to follow the viewer::
w, h = display.target_size or (display.width, display.height)
display.publish(render(state, w, h), pixel_ratio=display.target_ratio)
ws_url
property
¶
Browser-reachable WebSocket URL for this stream (e.g. ws://host:port/name).
Raises if the server is not bound yet (call await rfb.serve(...) first). A
wildcard bind host (0.0.0.0/::) is reported as 127.0.0.1.
aclose()
async
¶
Stop the server, disconnect viewers, and release encoder resources.
Source code in src/pdum/rfb/display.py
events()
async
¶
Async-iterate queued events (alternative to :meth:poll_events).
Yields :class:~pdum.rfb.types.InputEvent\ s and, under
serve(adaptive=True), :class:~pdum.rfb.types.DownscaleHint\ s. Use this or
:meth:poll_events — both drain the same queue.
Source code in src/pdum/rfb/display.py
poll_events()
¶
Drain and return everything queued since the last poll.
The list is ordinarily :class:~pdum.rfb.types.InputEvent\ s (input from all
connected viewers). With serve(adaptive=True) it may also carry a
:class:~pdum.rfb.types.DownscaleHint — a server-driven resolution suggestion —
interleaved in arrival order; tell them apart with isinstance.
Source code in src/pdum/rfb/display.py
publish(frame, *, pixel_ratio=None, color=None)
¶
Make frame the latest frame and wake every connected viewer.
Synchronous and non-blocking. frame may be:
- a contiguous host
uint8array —(H, W, 3)rgb24or(H, W, 4)rgba8; - a CUDA tensor exposing
__cuda_array_interface__(e.g. CuPy) of shape(H, W, 3|4)— published as a zero-copycudaframe (for NV12, or other frameworks, build aRawFramevia :func:pdum.rfb.gpu.cuda_frame); - an MLX (Apple Metal) array of shape
(H, W, 3|4)— published as ametalframe; the VideoToolbox encoder converts RGB(A)→NV12 on the GPU (for pre-converted NV12, use :func:pdum.rfb.metal.metal_frame); - a ready :class:
~pdum.rfb.types.RawFrame(anymemory).
Latest-frame-wins: a viewer that is behind simply skips intermediate frames.
Ownership. By default publish() borrows your buffer — it stores a bare
reference and reads the pixels asynchronously, on each viewer's encode worker
thread. The borrow window runs from here until every viewer has finished encoding
the frame, and it is widest under still_after (the resting frame is re-read
~still_after seconds later for the lossless still). So in borrow mode, publish a
fresh buffer each call, or do not mutate a published buffer until it is encoded.
Construct the Display (or call :func:~pdum.rfb.serve) with own_frames=True
to instead have the server copy each frame into a recycled buffer, after which you
may reuse/mutate your own buffer immediately (cpu/cuda only; metal raises).
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
pixel_ratio
|
float | None
|
Render-side DPR for this frame (device px per logical px). |
None
|
color
|
Any
|
Color descriptor for this frame — a :class: |
None
|
Source code in src/pdum/rfb/display.py
207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 | |
record(path, *, fps=None, bitrate=None, crf=None, preset='veryfast')
¶
Record the published-frame stream to a real MP4 file (server-side tap).
Returns a started :class:~pdum.rfb.recording.Recording — an async context
manager / handle with :meth:~pdum.rfb.recording.Recording.stop. It taps the
same latest-frame stream the viewer sessions pull from, so it is independent
of any connected browser and works fully headless (demos, CI artifacts,
golden-frame capture). Frames are H.264-encoded (libx264) and muxed to
AVCC-in-MP4 via a separate PyAV path — this is the one place AVCC/MP4 is
correct; it never touches the Annex-B WebSocket payload.
The real, monotonic per-frame timestamps are honored, so a variable-cadence
publisher (sparse still_after scenes included) records with correct frame
timing. Call it on the event-loop thread; it starts a background encode task.
::
async with display.record("out.mp4"):
for _ in range(300):
display.publish(render()); await asyncio.sleep(1 / 30)
# file finalized on exit — or use rec = display.record(...); await rec.stop()
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
path
|
str | Path
|
Output |
required |
fps
|
int | None
|
Advisory frame rate for the encoder's rate control (default: the display's
|
None
|
bitrate
|
int | None
|
Target bitrate in bits/s (average). Mutually exclusive with |
None
|
crf
|
int | None
|
Constant Rate Factor (0–51, lower = better) for constant-quality encoding. |
None
|
preset
|
str
|
libx264 speed/quality preset (default |
'veryfast'
|
Raises:
| Type | Description |
|---|---|
RuntimeError
|
If PyAV (the |
Source code in src/pdum/rfb/display.py
widget(*, batteries=True, base_path=None, host=None, **chrome)
¶
Return an anywidget viewer bound to this stream (needs the [anywidget] extra).
In a notebook: display.widget() renders the batteries viewer;
display.widget(batteries=False) the bare canvas. One widget = one Web Worker +
one WebSocket; the server multiplexes many streams on one port. For remote/HTTPS
notebooks, mount pdum.rfb.asgi.rfb_hub_endpoint same-origin and pass
base_path= (the widget then uses a same-origin wss:// URL). Extra keyword
args (e.g. show_toolbar=False) become widget traits.
Raises if the server is not bound yet.
Source code in src/pdum/rfb/display.py
encoders
¶
Encoder backends for the remote framebuffer.
ImageEncoder
¶
Encode CPU RGB/RGBA frames to JPEG, PNG or WebP.
Source code in src/pdum/rfb/encoders/image.py
encode_still(frame)
¶
Encode frame losslessly as PNG — the "still after settle" upgrade.
Independent of the streaming mode: once a scene settles, the resting
frame is re-sent pixel-exact so it is crisp even when the live stream is
lossy JPEG/WebP. Every image is already a keyframe, so the browser swaps it
in with no client-side changes.
Source code in src/pdum/rfb/encoders/image.py
available_video_encoders()
¶
build_encoder(selection, *, width, height, fps=30, bitrate=12000000, video_encoder='h264_cpu', pipeline_depth=0, color=None)
¶
Build the encoder backend described by selection.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
selection
|
BackendSelection
|
The result of :func: |
required |
width
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
height
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
fps
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
bitrate
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
video_encoder
|
str
|
Which registered video encoder to use for the H.264 transport. |
'h264_cpu'
|
pipeline_depth
|
int
|
Encoder pipeline depth. |
0
|
color
|
dict | None
|
Optional stream color descriptor ( |
None
|
Source code in src/pdum/rfb/encoders/base.py
register_video_encoder(name, factory)
¶
base
¶
Encoder registry and the build_encoder factory.
The registry is the extension seam for additional video encoders. The CPU
H.264 (PyAV/libx264) backend registers itself lazily so importing this module
never imports PyAV. The NVENC backends register themselves the same way and
:func:pdum.rfb.protocol.select_transport flips has_nvenc to prefer them.
available_video_encoders()
¶
build_encoder(selection, *, width, height, fps=30, bitrate=12000000, video_encoder='h264_cpu', pipeline_depth=0, color=None)
¶
Build the encoder backend described by selection.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
selection
|
BackendSelection
|
The result of :func: |
required |
width
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
height
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
fps
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
bitrate
|
int
|
Encoder configuration (ignored by the image encoder except where noted). |
required |
video_encoder
|
str
|
Which registered video encoder to use for the H.264 transport. |
'h264_cpu'
|
pipeline_depth
|
int
|
Encoder pipeline depth. |
0
|
color
|
dict | None
|
Optional stream color descriptor ( |
None
|
Source code in src/pdum/rfb/encoders/base.py
register_video_encoder(name, factory)
¶
h264_cpu
¶
CPU H.264 encoder via PyAV / libx264 (guide section 6).
Produces Annex B access units (in-band SPS/PPS on key frames) suitable for
the browser's WebCodecs VideoDecoder in Annex B mode. Configured for low
latency: ultrafast/zerolatency, no B-frames, periodic IDR.
Several gaps in the guide's sketch are fixed here:
- forced keyframes are real IDRs (
forced-idr=1+ per-framepict_type=I); - RGB is explicitly reformatted to
yuv420p(PyAV does not auto-convert); annexb=1/repeat-headers=1keep parameter sets in-band.
PyAV is imported lazily so this module can be imported (e.g. for
:func:h264_available) even where av is installed only as the optional
h264 extra.
H264CpuEncoder
¶
Encode CPU rgb24 frames to H.264 Annex B access units.
Source code in src/pdum/rfb/encoders/h264_cpu.py
70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 | |
encode_still(frame)
¶
A settled-scene still for the video path: a forced IDR of the frame.
True lossless H.264 isn't practical over WebCodecs, so the still here is a
clean, self-contained intra (IDR) of the resting frame — it refreshes the
image and lets a client that dropped deltas during a flurry jump straight
to the latest. Re-encoding advances frame_index so the PTS stays
monotonic. For a pixel-exact settled image, use the image transport.
Source code in src/pdum/rfb/encoders/h264_cpu.py
h264_available()
¶
h264_color_vui(color)
¶
Map a color descriptor to (primaries, transfer, matrix, range) ffmpeg codes.
Returns None for the default (color is None) so no VUI is written and existing
sRGB streams are bitstream-unchanged.
Source code in src/pdum/rfb/encoders/h264_cpu.py
h264_cpu_available()
¶
True if PyAV is importable and exposes the libx264 encoder.
Source code in src/pdum/rfb/encoders/h264_cpu.py
self_test(width=64, height=64, frames=8)
¶
Encode a few synthetic frames and decode them back to prove validity.
Returns True if the produced Annex B bitstream decodes to a plausible
number of frames at the expected resolution. Doubles as a runtime check
that libx264 is actually usable.
Source code in src/pdum/rfb/encoders/h264_cpu.py
image
¶
Image encoder: JPEG / PNG / WebP via Pillow.
The simplest backend (guide section 5). Every image is an independent frame, so every payload is a keyframe. Ideal for snapshots, debug views, low/medium frame rates, and mostly static plots.
ImageEncoder
¶
Encode CPU RGB/RGBA frames to JPEG, PNG or WebP.
Source code in src/pdum/rfb/encoders/image.py
encode_still(frame)
¶
Encode frame losslessly as PNG — the "still after settle" upgrade.
Independent of the streaming mode: once a scene settles, the resting
frame is re-sent pixel-exact so it is crisp even when the live stream is
lossy JPEG/WebP. Every image is already a keyframe, so the browser swaps it
in with no client-side changes.
Source code in src/pdum/rfb/encoders/image.py
nvenc_cpu
¶
Hardware H.264 encoder via NVIDIA NVENC (the roadmap's GPU backend).
This rides on PyAV's bundled ffmpeg rather than NVIDIA's PyNvVideoCodec:
the av wheel ships an ffmpeg built with --enable-nvenc, exposing the
h264_nvenc encoder, which dlopen\s libnvidia-encode at runtime. So
this backend needs no Python dependency beyond av (already the h264 /
nvenc extra) — only a host NVIDIA driver + an NVENC-capable GPU, which pip
cannot install.
Like :class:~pdum.rfb.encoders.h264_cpu.H264CpuEncoder it emits Annex B
access units (in-band SPS/PPS on key frames) configured for low latency
(preset=p4/tune=ll, no B-frames, ~1 s forced-IDR cadence). It subclasses
the libx264 encoder and only swaps the underlying av.CodecContext, so the
frame conversion, forced-keyframe handling, and payload packing are shared.
Availability is gated by :func:nvenc_cpu_available, which checks the OS, that PyAV
exposes h264_nvenc, and that the encoder actually opens on the GPU.
NvencCpuEncoder
¶
Bases: H264CpuEncoder
Encode CPU rgb24 frames to H.264 Annex B on the GPU via NVENC.
Drop-in for :class:H264CpuEncoder (same constructor and EncoderBackend
interface); only the underlying codec context differs. Frames narrower than
:data:NVENC_MIN_WIDTH are rejected because NVENC cannot open below it.
Source code in src/pdum/rfb/encoders/nvenc_cpu.py
nvenc_codec_available()
¶
True if PyAV is importable and lists the h264_nvenc encoder.
This is a cheap, side-effect-free check (it does not touch the GPU). It is
necessary but not sufficient — the encoder still has to open on a real
device, which :func:nvenc_cpu_available verifies.
Source code in src/pdum/rfb/encoders/nvenc_cpu.py
nvenc_cpu_available()
¶
True if a usable NVENC H.264 encoder is present (cached).
Guards, in order: the OS must be one where NVENC exists (not macOS); PyAV
must expose h264_nvenc; and the encoder must actually open and encode a
frame on the GPU (which requires an NVENC-capable device and a working
driver). The result is cached for the lifetime of the process.
Source code in src/pdum/rfb/encoders/nvenc_cpu.py
self_test(width=256, height=256, frames=8)
¶
Encode a few synthetic frames via NVENC and decode them back with PyAV.
Returns True if the produced Annex B bitstream decodes to a plausible
number of frames at the expected resolution. Doubles as a runtime check that
NVENC is actually usable end-to-end.
Source code in src/pdum/rfb/encoders/nvenc_cpu.py
nvenc_gpu_pdum
¶
PyAV-free GPU H.264 encoder via the NVENC SDK (pdum.nvenc).
The third GPU path, and the only PyAV-free one. Where
:class:~pdum.rfb.encoders.nvenc.NvencCpuEncoder uploads a host rgb24 frame and
:class:~pdum.rfb.encoders.nvenc_gpu_pyav.NvencGpuPyavEncoder needs PyAV ≥ 18, this
backend hands a GPU-resident NV12 buffer straight to NVIDIA's NvEncoderCuda (the
habemus-papadum-nvenc package, import pdum.nvenc) and gets Annex B back —
no ffmpeg/PyAV anywhere in the encode path. It is the fastest path measured on the
test hardware (see docs/performance.md).
Input handling mirrors the zero-copy CUDA backend:
- a CUDA
nv12frame — encoded as-is (the true zero-copy case); - a CUDA
rgb24/rgba8frame — converted to NV12 on the GPU first (:func:pdum.rfb.gpu.rgb_to_nv12); - a host
rgb24/rgba8frame — uploaded then converted (graceful fallback).
Gate on :func:nvenc_gpu_pdum_available before constructing one. Emits the same
low-latency Annex B as the other H.264 backends, so the wire format, forced-keyframe
handling, and payload packing are unchanged. Fixed-resolution: a resize rebuilds the
encoder (the session does this via its encoder_factory) and forces a keyframe.
NvencGpuPdumEncoder
¶
Encode CUDA (or host, with upload) frames to H.264 Annex B via the NVENC SDK.
Same constructor / :class:~pdum.rfb.types.EncoderBackend interface as the other
H.264 backends. Frames narrower than
:data:~pdum.rfb.encoders.nvenc.NVENC_MIN_WIDTH are rejected (NVENC cannot open
below it) and dimensions must be even (NV12).
Source code in src/pdum/rfb/encoders/nvenc_gpu_pdum.py
64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 | |
encode_still(frame)
¶
Settled-scene still: a forced IDR of the resting frame (see the CPU
backend's :meth:~pdum.rfb.encoders.h264_cpu.H264CpuEncoder.encode_still).
Source code in src/pdum/rfb/encoders/nvenc_gpu_pdum.py
nvenc_gpu_pdum_available()
cached
¶
True if the PyAV-free SDK NVENC path is usable in this process (cached).
Checks CuPy + pdum.nvenc importable, then actually opens an NVENC session and
encodes two frames (no decode, so it stays PyAV-free). Runs at most once per
process because it opens a real encoder session.
Source code in src/pdum/rfb/encoders/nvenc_gpu_pdum.py
self_test(width=256, height=256, frames=8)
¶
Encode synthetic CUDA NV12 frames via the SDK and decode them back (needs PyAV).
Source code in src/pdum/rfb/encoders/nvenc_gpu_pdum.py
nvenc_gpu_pyav
¶
Zero-copy CUDA → NVENC H.264 encoder (the roadmap's GPU-buffer path).
Unlike :class:~pdum.rfb.encoders.nvenc.NvencCpuEncoder — which takes a host
rgb24 frame, reformats it to yuv420p on the CPU, and lets NVENC upload it
— this backend encodes a CUDA-resident frame directly: a CuPy / DLPack NV12
buffer is handed to h264_nvenc via av.VideoFrame.from_dlpack with no host
round-trip. On the tested Ada laptop GPU that is ~2.4–4.3× lower per-frame latency
(1080p 2.5 ms vs 7.3 ms; 4K 7.1 ms vs 30.5 ms) and frees the CPU entirely — see
docs/gpu_zerocopy.md.
It accepts:
- a CUDA
nv12frame (RawFrame(memory="cuda", pixel_format="nv12")) — the true zero-copy case; the device buffer is encoded as-is; - a CUDA
rgb24/rgba8frame — converted to NV12 on the GPU first (:func:pdum.rfb.gpu.rgb_to_nv12, ~0.01 ms at 1080p); - a host
rgb24/rgba8frame — uploaded then converted (graceful fallback so agpu=Trueserver still works if the publisher pushes a host frame).
Requires PyAV ≥ 18 (or a from-source build with the CUDA-encode fix). Gate on
:func:pdum.rfb.gpu.cuda_zerocopy_available before constructing one. Emits the
same low-latency Annex B as the other H.264 backends, so the wire format,
forced-keyframe handling, and payload packing are inherited unchanged.
NvencGpuPyavEncoder
¶
Bases: H264CpuEncoder
Encode CUDA (or host, with upload) frames to H.264 Annex B via NVENC, zero-copy.
Drop-in for :class:~pdum.rfb.encoders.nvenc.NvencCpuEncoder (same
constructor / EncoderBackend interface); only the input handling differs.
Frames narrower than :data:~pdum.rfb.encoders.nvenc.NVENC_MIN_WIDTH are
rejected (NVENC cannot open below it), and dimensions must be even (NV12).
Source code in src/pdum/rfb/encoders/nvenc_gpu_pyav.py
41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 | |
nvenc_gpu_pyav_available()
¶
True if the zero-copy CUDA→NVENC path is usable (see :func:cuda_zerocopy_available).
self_test(width=256, height=256, frames=8)
¶
Encode a few synthetic CUDA NV12 frames via zero-copy NVENC and decode back.
Source code in src/pdum/rfb/encoders/nvenc_gpu_pyav.py
vtenc
¶
macOS H.264 EncoderBackend via Apple VideoToolbox (pdum.vtenc).
The rfb wrapper around the habemus-papadum-vtenc package (import pdum.vtenc): it
adapts VtEncoder (host NV12 → H.264 Annex B) to the :class:~pdum.rfb.types.EncoderBackend
protocol so it slots into the same registry/transport seam as the libx264 and NVENC
backends. Host rgb24/rgba8 frames are converted to NV12 on the CPU (BT.601 limited
range, matching :func:pdum.rfb.gpu.rgb_to_nv12); an already-NV12 frame passes straight
through. Emits the same low-latency Annex B (in-band SPS/PPS, no reordering), so the wire
format and the browser are unchanged.
The advertised codec string is taken from the actual emitted SPS
(VtEncoder.codec_string, e.g. avc1.420028 at 1080p), not the constant
avc1.42E01F — VideoToolbox picks the level from the resolution.
Gate on :func:vtenc_available before constructing one. Registered as "vtenc".
VideoToolboxEncoder
¶
Encode host frames to H.264 Annex B via Apple VideoToolbox.
Same constructor / :class:~pdum.rfb.types.EncoderBackend interface as the other H.264
backends. Dimensions must be even (NV12).
Source code in src/pdum/rfb/encoders/vtenc.py
59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 | |
encode_still(frame)
¶
Encode frame as a self-contained keyframe (a clean IDR) for "still after settle".
VideoToolbox has no lossless mode, so the video-path still is a forced IDR — the same
contract as the libx264/NVENC backends. Without this method the session's still gate
(hasattr(encoder, "encode_still")) is False and stills silently no-op on macOS.
Source code in src/pdum/rfb/encoders/vtenc.py
self_test(width=256, height=192, frames=8)
¶
Encode synthetic host frames through VideoToolbox and decode them back (needs PyAV).
Source code in src/pdum/rfb/encoders/vtenc.py
vtenc_available()
cached
¶
True if the VideoToolbox backend is usable in this process (cached).
macOS + pdum.vtenc importable + VideoToolbox can open an H.264 session.
Source code in src/pdum/rfb/encoders/vtenc.py
gpu
¶
GPU zero-copy helpers for the NVENC CUDA path (DLPack in, no host copy).
This module lets a CUDA-resident frame (a CuPy / PyTorch / any __dlpack__ or
__cuda_array_interface__ tensor) be encoded by NVENC without a round-trip
through host memory — the encoder reads the device buffer directly. It is the
zero-copy counterpart to :mod:pdum.rfb.encoders.nvenc_cpu (which uploads host
rgb24 and reformats to yuv420p on the CPU first).
Everything here lazy-imports CuPy, so import pdum.rfb.gpu is always safe; the
functions raise only when actually called without CuPy.
Requirements (see :func:cuda_zerocopy_available)
- CuPy (
cupy-cuda13x/cupy-cuda12x; cp314 wheels exist). - An NVENC-capable GPU + driver (same gate as the host NVENC backend).
- PyAV that can encode CUDA frames.
from_dlpack(frame creation) landed in PyAV 17.0, but feeding those frames to an encoder (hw_frames_ctxadopted beforeavcodec_open2) lands only in PyAV 18.0 (unreleased as of this writing; the fix is onmain— issue #2199). On PyAV 17.x this raises "hw_frames_ctx must be set when using GPU frames as input"; there is no pure-Python workaround (PyAV exposes no handle to setavctx->hw_frames_ctx), so a< 18install must build PyAV from source (mainor the small patch documented indocs/gpu_zerocopy.md).
Two non-obvious gotchas this module handles for you
- One shared CUDA context. CuPy uses the device primary context; FFmpeg's
CUDA hwcontext (
primary_ctx=True) wants it created withCU_CTX_SCHED_BLOCKING_SYNCflags. If CuPy activates it first with the default (auto) flags,primary_ctx=Truefails ("incompatible flags") and a separate (primary_ctx=False) context can't register CuPy's pointers ("resource register failed"). :func:enable_cuda_context_sharingpre-sets the flags — call it before any CuPy/Torch CUDA work (importing is fine; the first allocation/op is what activates the context). - NV12 must be one contiguous allocation (Y plane then UV plane), because
NVENC reads UV at
base + pitch*height. :func:rgb_to_nv12produces that layout; :func:nv12_planesslices it back into the two DLPack planes.
HostFrameAdapter
¶
Wrap a host :class:~pdum.rfb.types.EncoderBackend so it tolerates CUDA frames.
The image / CPU encoders expect host frames; when the publisher pushes CUDA
frames (serve(gpu=True)), an image-transport viewer's encoder is wrapped in
this adapter, which downloads each CUDA frame to host rgb24
(:func:to_host_rgb) before delegating. Host frames pass through untouched, so
the wrapped encoder stays dependency-pure and the existing contract is intact.
Source code in src/pdum/rfb/gpu.py
encode_still(frame)
¶
Download a CUDA frame to host, then delegate the still to the wrapped
encoder (keeps "still after settle" working in serve(gpu=True) +
image-transport viewers).
Source code in src/pdum/rfb/gpu.py
cuda_frame(array, *, pixel_format='auto', width=None, height=None, seq=0, timestamp_us=0)
¶
Wrap a device tensor as a CUDA :class:~pdum.rfb.types.RawFrame for publish().
pixel_format="auto" infers from shape: (H, W, 3) -> rgb24,
(H, W, 4) -> rgba8, 2-D (H+H//2, W) -> nv12. Pass an explicit
pixel_format (and height for ambiguous NV12) to override. The tensor
is referenced, not copied; keep it alive until the frame is encoded.
Source code in src/pdum/rfb/gpu.py
cuda_zerocopy_available()
cached
¶
True if the zero-copy CUDA→NVENC path is usable in this process (cached).
Checks, in order: CuPy importable; an NVENC-capable GPU/driver
(:func:pdum.rfb.encoders.nvenc_cpu.nvenc_cpu_available); and that PyAV can actually
encode a CUDA frame (PyAV ≥ 18 or a from-source build with the fix). The
self-test opens an NVENC session, so it runs at most once per process.
.. note::
Call :func:enable_cuda_context_sharing before any CuPy CUDA op for a
reliable result — if CuPy has already activated the primary context with
the default flags, the shared-context probe (and the encoder) will fail.
Source code in src/pdum/rfb/gpu.py
enable_cuda_context_sharing(device_id=0, *, sched=_FFMPEG_SCHED)
¶
Pre-set the device primary-context flags so CuPy and FFmpeg share one context.
Call this once, first thing in your program, before any CuPy/PyTorch CUDA
op. It sets the device's primary-context scheduling flags to what FFmpeg's
CUDA hwcontext wants (CU_CTX_SCHED_BLOCKING_SYNC); CuPy then retains that
same primary context, and the zero-copy encoder (primary_ctx=True) can
register CuPy's device pointers.
Returns True on success. Returns False (and changes nothing) if the
CUDA driver can't be loaded. If the primary context is already active with
different flags (e.g. CuPy already ran), the driver call may still succeed but
the flags will not take effect until the context is reset — so order matters.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
device_id
|
int
|
CUDA device ordinal. |
0
|
sched
|
str
|
One of |
_FFMPEG_SCHED
|
Source code in src/pdum/rfb/gpu.py
nv12_height(packed)
¶
nv12_planes(packed)
¶
Slice a contiguous NV12 (H + H//2, W) buffer into (Y, UV) planes.
The two returned CuPy arrays are views into packed (no copy), suitable
for av.VideoFrame.from_dlpack([y, uv], format="nv12", ...).
Source code in src/pdum/rfb/gpu.py
rgb_to_nv12(rgb, *, out=None)
¶
Convert a device rgb24 tensor (H, W, 3) to contiguous NV12.
Returns a CuPy uint8 array of shape (H + H//2, W): the Y plane
(H rows) immediately followed by the interleaved UV plane (H//2
rows) — the single-allocation layout NVENC requires. Pass out to reuse a
buffer across frames (the zero-copy encoder does this).
Source code in src/pdum/rfb/gpu.py
to_host_rgb(frame)
¶
Download a CUDA frame to a contiguous host numpy rgb24 array.
Used by the image / CPU H.264 encoders so an image-only (or CPU-fallback)
client still works when the publisher pushes CUDA frames. rgba8 drops
alpha; nv12 is converted to rgb24 on the GPU first. A host frame is
returned (coerced to 3-channel) unchanged.
Source code in src/pdum/rfb/gpu.py
metal
¶
Apple Metal / MLX GPU frame helpers — the unified-memory analog of :mod:pdum.rfb.gpu.
Where :mod:pdum.rfb.gpu lets a CUDA tensor be encoded by NVENC without a host round-trip,
this module lets an MLX (Apple Metal, unified-memory) frame be converted RGB(A) → NV12 on
the GPU with a custom mx.fast.metal_kernel and handed to the VideoToolbox encoder — instead
of the CPU color-conversion pass (~6.6 ms at 1080p, and it pegs a core). On Apple Silicon there is
no PCIe upload to eliminate, so the remaining copy (host NV12 → CVPixelBuffer) is negligible
(≤2 % of frame time, measured); the win here is moving the color conversion onto the GPU.
Everything lazy-imports mlx so import pdum.rfb never requires it. macOS + MLX only; gate on
:func:mlx_available. The natural producer is a render kernel that writes an (H, W, 4) RGBA
mx.array (see examples/mlx_vt_stream.py); publish it directly (display.publish(rgba) —
an MLX array is recognized as a memory="metal" frame) or wrap a pre-converted NV12 array with
:func:metal_frame.
MetalHostFrameAdapter
¶
Wrap a host :class:~pdum.rfb.types.EncoderBackend so it tolerates Metal frames.
The Metal analog of :class:pdum.rfb.gpu.HostFrameAdapter: when the publisher pushes MLX
(Metal) frames under serve(gpu=True) on macOS, an image-transport viewer's encoder is
wrapped in this adapter, which downloads each Metal frame to host rgb24
(:func:to_host_rgb) before delegating. Host frames pass through untouched.
Source code in src/pdum/rfb/metal.py
materialize(array)
¶
Force a lazy MLX array to compute on the calling thread. MLX binds a lazy graph's
nodes to the thread's default stream, so an array built on the publish/loop thread cannot be
evaluated on the session's encode worker thread ("no Stream(gpu, 0) in current thread").
:meth:Display.publish calls this for Metal frames so the render is materialized on the loop
thread; the (cheap) NV12 conversion then runs safely on the worker thread over the eager buffer.
Source code in src/pdum/rfb/metal.py
metal_frame(array, *, pixel_format='auto', width=None, height=None, seq=0, timestamp_us=0)
¶
Wrap an MLX (Metal) array as a memory="metal" :class:~pdum.rfb.types.RawFrame for
publish() — the Metal analog of :func:pdum.rfb.gpu.cuda_frame.
pixel_format="auto" infers from shape: (H, W, 3) → rgb24, (H, W, 4) → rgba8,
2-D (H+H//2, W) → nv12. Use this for a pre-converted NV12 array; a plain RGBA array
can be handed straight to display.publish() (it is recognized as a Metal frame). The array
is referenced, not copied; keep it alive (and evaluated) until it is encoded.
Source code in src/pdum/rfb/metal.py
mlx_available()
cached
¶
True if MLX (Apple Metal) is usable in this process (cached). macOS + mlx importable.
Source code in src/pdum/rfb/metal.py
rgb_to_nv12(rgb)
¶
Convert a Metal rgb24/rgba8 MLX array (H, W, 3|4) to a contiguous NV12 MLX
array (H + H//2, W) on the GPU. Returns a lazy mx.array (call :func:to_host_nv12
or mx.eval to materialize). Even dimensions required.
Source code in src/pdum/rfb/metal.py
to_host_frame(frame)
¶
Return frame with its data on the host: a Metal frame is downloaded to a host
rgb24 :class:~pdum.rfb.types.RawFrame (:func:to_host_rgb); any other frame is
returned unchanged. Lets the CPU / image encoders accept a published MLX frame — the frame
was materialized on the publish thread, so this cross-thread read is safe.
Source code in src/pdum/rfb/metal.py
to_host_nv12(array)
¶
Evaluate a Metal NV12 MLX array and return a contiguous host numpy view. Unified memory
makes np.asarray a near-zero-copy handoff; the VideoToolbox binding then does the small
CVPixelBuffer copy.
Source code in src/pdum/rfb/metal.py
to_host_rgb(frame)
¶
Download a Metal frame to a contiguous host numpy rgb24 array — used by the image /
CPU encoders so an image-only client still works under serve(gpu=True) on macOS. rgba8
drops alpha; nv12 is converted back to RGB (BT.601 limited) on the host.
Source code in src/pdum/rfb/metal.py
metrics
¶
Per-session performance metrics.
Tracks the quantities the implementation guide lists (section 14): encode time,
payload bytes, in-flight depth, round-trip ACK latency (send -> displayed),
client decode-queue depth, and derived rates (fps, bitrate). The session feeds
these; :meth:SessionMetrics.snapshot returns a plain dict for logging, the
GET /metrics side channel, and the adaptive-quality controller.
Latencies use an exponential moving average so the "recent" value reacts quickly
without storing history; counts are cumulative. Throughput rates (fps, bitrate) are
computed over a short rolling time window (:data:_RATE_WINDOW_S) so they track the
current cadence — a lifetime average would crawl toward a new frame rate over
minutes and never settle.
SessionMetrics
dataclass
¶
Mutable accumulator of one session's performance counters.
Source code in src/pdum/rfb/metrics.py
snapshot(*, now)
¶
Return a JSON-serializable view including derived rates.
Source code in src/pdum/rfb/metrics.py
notebook
¶
anywidget (Jupyter + marimo) front-end for pdum.rfb.
Optional — install the extra::
uv add 'habemus-papadum-rfb[anywidget]'
The widget loads a single self-contained ESM bundle (the Web Worker is inlined) that drives
the same RemoteFramebufferView as the standalone browser client. Frames travel over a
plain WebSocket, not the Jupyter/ipywidgets kernel comm — so the notebook only carries
the url/token traits, never pixels.
One widget = one Web Worker + one WebSocket. The Python Server hub multiplexes many
streams on one port, so N cells = N widgets = N independent streams (see
docs/notebook.md). Typical use::
import pdum.rfb as rfb
from pdum.rfb.notebook import publish_loop
display = await rfb.serve(1280, 720, port=0) # top-level await; loop already running
task = publish_loop(display, lambda: render(), fps=30) # non-blocking background task
display.widget() # -> batteries viewer in the cell
RfbCanvas
¶
Bases: AnyWidget
Bare tier: just the framebuffer canvas filling the cell — you supply the chrome/CSS.
Connection traits (url/host/base_path/port/stream/token/
image_only/main_thread_present) are connect-time: mutating one rebuilds the view.
main_thread_present defaults host-aware — True only under marimo (draws on the main
thread so the widget survives marimo's reparenting), False elsewhere (the lower-overhead
OffscreenCanvas path that also carries the backend-switch/zoom chrome). height sizes the
output (a notebook output <div> is 0-height by default, so the canvas would fall
back to 320×240 without it). state/stats/last_error are read back from JS.
Source code in src/pdum/rfb/notebook.py
RfbViewer
¶
Bases: RfbCanvas
Batteries tier: status pill + latency badge + toggleable stats HUD + toolbar.
Same connection traits as :class:RfbCanvas, with the chrome on by default. Theme via
the CSS custom properties on .rfb-root (a cell-injected <style> or a JupyterLab
theme); drop chrome per-widget with show_toolbar=False / show_stats=False.
Source code in src/pdum/rfb/notebook.py
publish_loop(display, render, *, fps=30)
¶
Schedule render() → display.publish() as a background task; return it immediately.
Non-blocking, so a notebook cell keeps going while frames flow (Jupyter/marimo already
run an asyncio loop, and await rfb.serve(...) works with top-level await). Tear down
with task.cancel() then await display.aclose(). To handle input, poll
display.poll_events() from inside render or run your own loop instead; the event
queue is bounded, so leaving it unpolled is safe.
Source code in src/pdum/rfb/notebook.py
protocol
¶
Wire protocol: binary envelope, header builders, and capability negotiation.
The transport is transport-neutral JSON for control plus a simple binary envelope for image/video payloads::
uint32le header_byte_length
utf8 JSON header
raw payload bytes
These functions are pure (no I/O) so they are fully unit-testable and the wire
shape is defined in exactly one place, shared by the session and the tests. The
binary envelope must stay byte-for-byte compatible with the JavaScript
unpackBinaryMessage in widgets/src/protocol.ts.
BackendSelection
dataclass
¶
The encoder/transport the server chose for a connection.
Source code in src/pdum/rfb/protocol.py
UnsupportedClient
¶
config_message(*, transport, width, height, codec=None, pixel_ratio=1.0, color=None, coords='frame-pixels')
¶
Build the server config control message (sent right after hello).
coords declares the event coordinate space the client sends (always
"frame-pixels" in this version — pointer/wheel x/y index the published
framebuffer directly). pixel_ratio is the frame's render DPR and color an
optional color descriptor (the dict form of a
:class:~pdum.rfb.types.ColorSpace); both are omitted when at their defaults so
older clients are unaffected.
Source code in src/pdum/rfb/protocol.py
header_for(p)
¶
image_header(p)
¶
Build the binary-envelope header for an image frame.
Source code in src/pdum/rfb/protocol.py
pack_binary_message(header, payload)
¶
Pack a header dict and payload bytes into a single binary message.
The header is encoded as compact UTF-8 JSON (no spaces) prefixed by its
little-endian uint32 byte length.
Source code in src/pdum/rfb/protocol.py
parse_control(text)
¶
select_transport(client_supported, *, has_h264, has_nvenc=False, prefer_video=True, image_mode='jpeg')
¶
Choose the best backend given client capabilities and server encoders.
Policy (guide section 12): if the client supports WebCodecs/H.264, the
server prefers video and at least one H.264 encoder is available, pick
H.264 (NVENC is preferred over the CPU path when present). Otherwise fall
back to the best mutually-supported image format. has_nvenc is accepted
now so the NVENC backend can be slotted in later without touching callers.
Raises:
| Type | Description |
|---|---|
UnsupportedClient
|
If no mutually-supported transport exists. |
Source code in src/pdum/rfb/protocol.py
stats_message(*, server_queue, dropped)
¶
Build a server stats control message.
unpack_binary_message(buf)
¶
Inverse of :func:pack_binary_message.
Returns:
| Type | Description |
|---|---|
tuple[dict, bytes]
|
The decoded JSON header and the raw payload bytes. |
Source code in src/pdum/rfb/protocol.py
video_header(p)
¶
Build the binary-envelope header for an encoded video access unit.
Source code in src/pdum/rfb/protocol.py
recording
¶
Server-side MP4 recording: tap the published-frame stream and mux to a file.
A convenience on :class:~pdum.rfb.display.Display (:meth:Display.record) that
records what you are publishing to a real MP4 file, independent of any connected
browser — so it works fully headless (demos, CI artifacts, golden-frame capture).
Design notes (why this is not the wire path):
- It reuses the H.264 encoder (libx264) but muxes to a real MP4 container. This is
the one place AVCC-in-MP4 is correct — the wire invariant "Annex B only, never
route H.264 through an mp4 muxer" is about the WebSocket payload; a file
recording wants exactly the AVCC-in-MP4 the wire forbids. So this is a separate
PyAV encode→mux pipeline (
av.open(..., "w")+add_stream("libx264")+container.mux); it never touches the sessions' Annex B bitstream. - It honors the real monotonic timestamps that already ride each
:class:
~pdum.rfb.types.RawFrame, so a variable-cadence publisher (sparsestill_afterscenes included) records with correct frame timing (VFR): each frame's PTS is its real timestamp relative to the first recorded frame.
PyAV is imported lazily (the [h264] extra), so import pdum.rfb — and even
import pdum.rfb.recording — stays dependency-light.
Recording
¶
Handle for an in-progress server-side MP4 recording.
Created (and started) by :meth:~pdum.rfb.display.Display.record. Use it as an async
context manager (auto-finalizes on exit) or call :meth:stop explicitly::
async with display.record("out.mp4"):
... # publish frames
# -- or --
rec = display.record("out.mp4")
...
await rec.stop() # flush the encoder, finalize the container
Attributes:
| Name | Type | Description |
|---|---|---|
frames_written |
Count of frames encoded into the file so far (useful for tests / progress). |
Source code in src/pdum/rfb/recording.py
88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 | |
stop()
async
¶
Stop recording and finalize the MP4 file (idempotent).
Signals the tap to unpark, awaits the encode task (which flushes the encoder and
closes the container in its finally), then re-raises any error the task hit.
Source code in src/pdum/rfb/recording.py
recording_available()
¶
rendercanvas
¶
A rendercanvas <https://rendercanvas.readthedocs.io>_ backend that streams over pdum.rfb.
rendercanvas is the canvas-abstraction layer under wgpu / pygfx /
fastplotlib: a render engine targets an abstract canvas and a backend decides where
the pixels go (a glfw window, a Qt widget, an offscreen buffer, a notebook...). This
module is a backend whose pixels go to a :class:pdum.rfb.Display — i.e. a wgpu/
pygfx app renders unchanged and the result streams to the browser over this library's
WebSocket + Web-Worker pipeline (image or H.264/WebCodecs), with input flowing back.
It is the spiritual equivalent of rendercanvas's own jupyter_rfb backend, but on
this library's transport. Cross-platform: the "bitmap" present method downloads
the rendered frame to a host numpy array, so it works identically on macOS and Linux
(no CUDA/NVENC required). The GPU zero-copy path is a separate, Linux-only future track
(see docs/proposals/active/wgpu_nvenc_zerocopy.md).
Usage (own your asyncio loop; reuse :func:pdum.rfb.serve)::
import asyncio, pdum.rfb as rfb
from pdum.rfb.rendercanvas import RfbRenderCanvas, loop
import pygfx
async def main():
display = await rfb.serve(1280, 720, port=8765)
canvas = RfbRenderCanvas(display=display, size=(1280, 720))
renderer = pygfx.renderers.WgpuRenderer(canvas)
scene, camera = build_scene() # your pygfx scene
controller = pygfx.OrbitController(camera, register_events=renderer)
def animate():
renderer.render(scene, camera)
canvas.request_draw(animate)
canvas.request_draw(animate)
try:
await loop.run_async() # runs on the current asyncio loop
finally:
await display.aclose()
asyncio.run(main())
Notes
- Browser input (pointer / wheel / key) is drained from the display and delivered to the
canvas event system, so
pygfxcontrollers (orbit camera, etc.) work. When you use this backend the events go to the canvas (canvas.add_event_handler/ controllers), not todisplay.poll_events()— the backend drains that queue for you. - The canvas size (set at construction or via
set_logical_size) is the render resolution and what gets published. Browserresizeis informational here (the publisher owns the resolution), so it is not auto-applied — matching the shared-display model. Keep the size even for the H.264 path. - Event-schema note: this library emits the
renderview <https://github.com/pygfx/renderview>_ vocabulary (type/timestamp);rendercanvas2.x still consumes the legacy keys (event_type/time_stamp). :func:_to_rendercanvas_eventrenames them — the values (logical coords,1=left/2=right/3=middlebuttons, tuplebuttons, capitalizedmodifiers) already match, so it is a pure key-rename. Whenrendercanvasadoptstypeit collapses to the identity.
RfbCanvasGroup
¶
RfbRenderCanvas
¶
Bases: BaseRenderCanvas
A rendercanvas backend that publishes each rendered frame to a :class:~pdum.rfb.display.Display.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
display
|
Display
|
A started :class: |
required |
size
|
tuple[int, int] | None
|
Logical canvas size |
None
|
**kwargs
|
Any
|
Forwarded to :class: |
{}
|
Source code in src/pdum/rfb/rendercanvas.py
111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 | |
server
¶
WebSocket server + demo CLI for the push-model remote framebuffer.
:func:serve starts a websockets server in the background and returns a
live :class:~pdum.rfb.display.Display. The application owns its loop and pushes
frames with :meth:Display.publish; each connecting browser is negotiated a
transport and driven by its own :class:~pdum.rfb.session.RfbSession, all fed from
the display's latest frame.
The same port answers a small HTTP side channel used by the headless e2e harness:
GET /health->ok(readiness probe for Playwright'swebServer)GET /recorded-events-> JSON list of every input event receivedGET /recorded-events/reset-> clears the list (per-test isolation)GET /metrics-> JSON array, one object per active session
python -m pdum.rfb.server is a self-contained demo: it owns a publish loop
streaming a deterministic pattern so a browser (or Playwright) can connect.
Server
¶
A hub: one WebSocket listener fronting several named streams.
Each stream is an independent :class:~pdum.rfb.display.Display with its own
encoder config; a browser selects one by URL path (ws://host/<stream>),
and a connection with no path lands on the "default" stream. Streams are
discoverable over HTTP at GET /streams.
Build one with :func:serve_server, or use :func:serve for the common
single-default-stream case (it returns the default Display and keeps
display.server pointing back here so you can add_stream more).
This composes with everything else — multi-client fan-out, per-client
backpressure, the encoders, auth, "still after settle" — none of which changes;
a stream is just a Display plus its config, and routing is purely additive.
Source code in src/pdum/rfb/server.py
427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 464 465 466 467 468 469 470 471 472 473 474 475 476 477 478 479 480 481 482 483 484 485 486 487 488 489 490 491 492 493 494 495 496 497 498 499 500 501 502 503 504 505 506 507 508 509 510 511 512 513 514 515 516 517 518 519 520 521 522 523 524 525 526 527 528 529 530 531 532 533 534 535 536 537 538 539 540 541 542 543 544 545 546 547 548 549 550 551 552 553 554 555 556 557 558 559 560 561 562 563 564 565 566 567 568 569 570 571 572 573 574 575 576 577 578 579 580 581 582 583 584 585 586 587 588 589 590 591 592 593 594 595 596 597 598 599 600 601 602 603 604 605 606 607 608 609 610 611 612 613 614 615 616 617 618 619 620 621 622 623 624 625 626 627 628 629 630 631 632 633 634 635 | |
port
property
¶
The bound TCP port (the actual one when started with port=0).
streams
property
¶
The names of the registered streams.
aclose()
async
¶
Stop the listener and disconnect every viewer of every stream.
Source code in src/pdum/rfb/server.py
add_stream(name, width, height, *, fps=30, bitrate=12000000, max_inflight=2, has_h264=None, has_nvenc=None, gpu=False, adaptive=False, still_after=None, stats_interval=None, authenticate=None, record_events=False, event_log=None, event_queue_size=4096, own_frames=False, resize_policy='publisher', max_render_dimension=None, encode_pipeline_depth=0)
¶
Register a new named stream and return its :class:Display.
Streams are independent: each carries its own encoder config (one GPU, one
image; per-stream bitrate; per-stream authenticate). Safe to call before
or after :meth:start — clients reach it at ws://host/<name> either way.
Raises if name is already taken. own_frames / resize_policy /
max_render_dimension are forwarded to the stream's :class:Display.
Source code in src/pdum/rfb/server.py
process_request(connection, request)
¶
Answer the HTTP side-channel routes; return None to proceed with WS.
Global: GET /health, GET /streams, GET /streams/<name>/metrics.
For backward compatibility the single-stream routes (/metrics,
/recorded-events, /recorded-events/reset) act on the "default"
stream when one exists.
Source code in src/pdum/rfb/server.py
remove_stream(name)
¶
Remove a named stream, disconnecting its viewers — the inverse of :meth:add_stream.
No-op if name is absent. The stream's :class:Display is closed locally
(viewers dropped, waiters woken) but the shared listener keeps running for the
other streams; a later connection to name closes with 4404. Used by the
demo hub to reap idle per-client streams.
Source code in src/pdum/rfb/server.py
start()
async
¶
Start the shared listener in the background; returns self.
Source code in src/pdum/rfb/server.py
stream(name=DEFAULT_STREAM)
¶
serve(width, height, *, host='127.0.0.1', port=8765, fps=30, bitrate=12000000, max_inflight=2, has_h264=None, has_nvenc=None, gpu=False, adaptive=False, still_after=None, stats_interval=None, authenticate=None, origins=None, record_events=False, event_log=None, event_queue_size=4096, own_frames=False, resize_policy='publisher', max_render_dimension=None, encode_pipeline_depth=0)
async
¶
Start the RFB WebSocket server in the background and return a :class:Display.
You own your loop: display = await serve(w, h, port=...) then call
display.publish(frame) whenever you like, and await display.aclose() to
shut down.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
width
|
int
|
Initial framebuffer size (a connecting client is configured to the display's current size; publish a different shape to resize). |
required |
height
|
int
|
Initial framebuffer size (a connecting client is configured to the display's current size; publish a different shape to resize). |
required |
has_h264
|
bool | None
|
|
None
|
has_nvenc
|
bool | None
|
|
None
|
gpu
|
bool
|
Opt in to GPU encode: the publisher pushes CUDA frames (CuPy/DLPack NV12
or rgb) and each viewer's H.264 encoder reads them directly, no host copy.
Prefers the PyAV-free NVENC SDK backend ( |
False
|
still_after
|
float | None
|
Opt in to "still after interaction settles": when no new frame is
published for |
None
|
adaptive
|
bool
|
Enable adaptive quality (bitrate → fps → in-flight, with recovery): the
encoder is rebuilt as the controller reacts to the client's decode-queue
depth and RTT. Pairs well with |
False
|
stats_interval
|
float | None
|
Opt in to a periodic server→client |
None
|
authenticate
|
Authenticator | None
|
Optional async hook (see :mod: |
None
|
origins
|
list[str | None] | None
|
Allowed |
None
|
own_frames
|
bool
|
Opt in to server-owned frames: |
False
|
resize_policy
|
str
|
Opt in to match-client resize: |
'publisher'
|
max_render_dimension
|
str
|
Opt in to match-client resize: |
'publisher'
|
encode_pipeline_depth
|
int
|
Encoder pipeline depth. |
0
|
Notes
This hosts a single "default" stream. Reach the hub behind it via
display.server to host several streams from the one port
(display.server.add_stream("camera_b", 640, 480)), or start with
:func:serve_server for a hub with no default stream. See
docs/multiple_streams.md.
Source code in src/pdum/rfb/server.py
638 639 640 641 642 643 644 645 646 647 648 649 650 651 652 653 654 655 656 657 658 659 660 661 662 663 664 665 666 667 668 669 670 671 672 673 674 675 676 677 678 679 680 681 682 683 684 685 686 687 688 689 690 691 692 693 694 695 696 697 698 699 700 701 702 703 704 705 706 707 708 709 710 711 712 713 714 715 716 717 718 719 720 721 722 723 724 725 726 727 728 729 730 731 732 733 734 735 736 737 738 739 740 741 742 743 744 745 746 747 748 749 750 751 752 753 754 755 756 757 | |
serve_server(*, host='127.0.0.1', port=8765, origins=None, streams=None)
async
¶
Start a multi-stream hub and return the :class:Server.
Unlike :func:serve (one default stream, returns its Display), this returns
the hub itself with no default stream. Add streams with
server.add_stream(name, w, h, **config) — each returns its own Display to
publish into — and clients attach by URL path (ws://host/<name>). A client
with no path is rejected until a "default" stream exists.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
host
|
str
|
As for :func: |
'127.0.0.1'
|
port
|
str
|
As for :func: |
'127.0.0.1'
|
origins
|
str
|
As for :func: |
'127.0.0.1'
|
streams
|
list[dict[str, Any]] | None
|
Optional list of |
None
|
Examples:
>>> server = await serve_server(port=8765)
>>> cam = server.add_stream("camera", 1280, 720)
>>> depth = server.add_stream("depth", 640, 480, has_h264=False)
>>> cam.publish(render_camera()); depth.publish(render_depth())
>>> await server.aclose()
Source code in src/pdum/rfb/server.py
session
¶
The WebSocket session loop and its backpressure / keyframe policy.
The policy matters more than the plumbing (guide sections 9 and 10):
- latest-frame-wins backpressure: never keep more than
max_inflightpayloads unacknowledged; drop stale frames rather than letting latency grow; - the first payload to a new client is a keyframe, and a keyframe is forced
again after any drop and on an explicit
request_keyframe; - video encoders are fixed-resolution, so the encoder is rebuilt (and a keyframe forced) whenever the incoming frame size changes.
CPU-bound encoding runs in a worker thread via :func:asyncio.to_thread so the
receive loop keeps draining ACKs, and the two loops run under a
:class:asyncio.TaskGroup for clean structured shutdown.
RfbSession
¶
Drive one client connection: encode + send frames, receive events.
Source code in src/pdum/rfb/session.py
44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 434 435 436 437 438 439 440 441 442 443 444 445 446 447 448 449 450 451 452 453 454 455 456 457 458 459 460 461 462 463 | |
metrics_snapshot()
¶
Return a JSON-serializable snapshot of this session's metrics.
Source code in src/pdum/rfb/session.py
request_reconfigure(*, factory=None, selection=None, bitrate=None, fps=None)
¶
Queue a live encoder swap and/or quality change.
Applied before the next encode (never mid-encode). factory swaps the encoder
backend/transport; selection (a :class:~pdum.rfb.protocol.BackendSelection)
triggers a fresh config to the client; bitrate/fps retune quality.
Must be called on the event-loop thread (it only stores state).
Source code in src/pdum/rfb/session.py
run()
async
¶
Run the receive and encode loops until the connection closes.
Source code in src/pdum/rfb/session.py
sources
¶
Frame sources: base bookkeeping plus a render-callback adapter.
A :class:FrameSource produces raw frames and consumes user-input events.
:class:BaseFrameSource owns the boring-but-easy-to-get-wrong bookkeeping
(sequence numbers, microsecond timestamps, fps pacing, event recording and
viewport tracking) so concrete sources only implement :meth:render.
The deterministic, GUI-free :class:~pdum.rfb.testing.SyntheticFrameSource
used by the tests and the demo server lives in :mod:pdum.rfb.testing.
BaseFrameSource
¶
Bases: ABC
Common bookkeeping for frame sources.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
width
|
int
|
Initial framebuffer size in pixels (forced even for codec friendliness). |
640
|
height
|
int
|
Initial framebuffer size in pixels (forced even for codec friendliness). |
640
|
fps
|
int
|
Target frame rate used for pacing when |
30
|
pixel_format
|
PixelFormat
|
|
'rgb24'
|
max_frames
|
int | None
|
If set, :meth: |
None
|
pace
|
bool
|
When true, :meth: |
True
|
clock
|
Callable[[], float] | None
|
Monotonic clock returning seconds; injectable for deterministic tests. |
None
|
Source code in src/pdum/rfb/sources.py
24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 | |
render(seq, t_us)
abstractmethod
¶
Return the pixel array for frame seq at timestamp t_us.
Must be deterministic in seq and match self.pixel_format.
OnDemandFrameSource
¶
Bases: BaseFrameSource
A sparse, event-driven source that renders only when marked dirty.
For scientific visualization the framebuffer often changes only when the user
interacts or a parameter updates (guide addendum, section 1). Instead of
fabricating duplicate frames at a fixed rate, :meth:next_frame parks until
:meth:mark_dirty is called (or, by default, until an input event arrives),
then emits a single frame with a real timestamp. The session's latest-frame-
wins policy and the encoder's keyframe handling are unchanged.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
render
|
Callable[[int, int], ndarray]
|
|
required |
render_on_event
|
bool
|
When true (default), any received input event marks the source dirty so interaction re-renders automatically. |
True
|
Source code in src/pdum/rfb/sources.py
RenderCallbackSource
¶
Bases: BaseFrameSource
Adapt a plain render(seq, t_us) -> ndarray callable into a source.
Source code in src/pdum/rfb/sources.py
testing
¶
Test and demo helpers (omitted from coverage on purpose).
Contains the headless-rendering :class:SyntheticFrameSource, in-memory fakes
for driving the session without real sockets/encoders, Annex B / NAL helpers for
validating H.264 output, and a fixture generator that keeps the JavaScript wire
protocol byte-compatible with the Python one.
The :func:render_test_pattern formula is the shared contract re-implemented
in widgets/tests so the browser e2e can verify decoded pixels against a
locally computed expectation.
FakeEncoder
¶
A deterministic in-memory encoder for session tests (no PyAV needed).
Source code in src/pdum/rfb/testing.py
encode_still(frame)
¶
A distinguishable 'still' payload for "still after settle" tests.
Source code in src/pdum/rfb/testing.py
FakeWebSocket
¶
In-memory duplex stand-in for a websockets connection.
sent collects every outbound message. Inbound messages are queued via
:meth:inject and yielded by async iteration until :meth:close.
Source code in src/pdum/rfb/testing.py
SyntheticFrameSource
¶
Bases: BaseFrameSource
Deterministic, GUI-free frame source for tests and the demo server.
Source code in src/pdum/rfb/testing.py
decode_annexb(data)
¶
Decode an H.264 Annex B byte stream back to frames using PyAV.
Returns a list of av.VideoFrame. Used by the headless encoder tests to
prove the produced bitstream is valid and decodable without a browser.
Source code in src/pdum/rfb/testing.py
expected_quadrant_color(seq, quadrant)
¶
Return the expected RGB color of quadrant (0..3) at frame seq.
gen_fixtures(out_dir)
¶
Generate protocol parity fixtures for the JavaScript test suite.
Writes <name>.bin (the packed binary message) and <name>.json
(the expected header + payload hex) for a few canonical messages.
Source code in src/pdum/rfb/testing.py
h264_sps_reorder_info(annexb)
¶
Parse the SPS of an Annex B stream and return its reorder-relevant fields.
Keys: profile_idc, level_idc, vui_present, bitstream_restriction_flag,
max_num_reorder_frames (None unless the restriction is present),
max_dec_frame_buffering.
Why this matters: a WebCodecs hardware VideoDecoder that does not see
max_num_reorder_frames=0 assumes worst-case reordering and buffers up to the level's
DPB size before its first output. Under the session's small max_inflight that starves
and the canvas silently freezes (no error). So the encoder must signal zero reordering.
Source code in src/pdum/rfb/testing.py
336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 401 402 403 404 405 406 407 408 409 410 411 412 413 414 415 416 417 418 419 420 421 422 423 424 425 426 427 428 429 430 431 432 433 | |
has_sps_pps_idr(annexb)
¶
loopback_server(handler, *, host='127.0.0.1', port=0)
async
¶
Run a real websockets server for one integration test.
Yields (host, port) of the listening server; the OS assigns a free port
when port is 0.
Source code in src/pdum/rfb/testing.py
nal_types(annexb)
¶
parse_nal_units(annexb)
¶
Split an Annex B byte stream into (nal_type, body) tuples.
Source code in src/pdum/rfb/testing.py
render_pattern(name, seq, width, height)
¶
Render any named pattern (used by the benchmark harness).
Source code in src/pdum/rfb/testing.py
render_test_pattern(seq, width, height)
¶
Canonical deterministic RGB test pattern (shared with the JS e2e tests).
Four quadrants, each a flat color that cycles by seq so consecutive
frames differ (exercising inter-frame compression) while interior pixels
stay flat (robust to lossy decoding). The browser test recomputes the
expected quadrant colors for the displayed seq and compares.
Source code in src/pdum/rfb/testing.py
starts_with_start_code(data)
¶
True if data begins with an Annex B start code (not AVCC length).
transport
¶
Transport seam between :class:~pdum.rfb.session.RfbSession and the socket.
The session is deliberately ignorant of how bytes move: it only needs to
await send(...) and async for over inbound messages. :class:Channel
captures exactly that surface, and :class:WebSocketTransport adapts a
websockets connection to it.
This is the additive transport seam: the Starlette/ASGI WebSocket adapter
(mapping WebSocketDisconnect onto the ConnectionClosed type the session
already catches) drops in here, with no change to the session, encoders, or sources.
Channel
¶
Bases: Protocol
The minimal duplex byte/text channel the session drives.
Source code in src/pdum/rfb/transport.py
__aiter__()
¶
WebSocketTransport
¶
Adapt a websockets server connection to the :class:Channel surface.
A raw websockets connection already satisfies :class:Channel; this thin
wrapper exists as the documented seam (and one place to translate disconnect
semantics for non-websockets transports later).
Source code in src/pdum/rfb/transport.py
types
¶
Core data types and protocols for the remote framebuffer.
This module is intentionally dependency-free (no Pillow / PyAV / websockets) so
that import pdum.rfb.types is always cheap and safe, even in environments
that only need the type definitions.
The design follows three independent concerns (see the implementation guide):
Frame source -> Encoder backend -> Transport backend
ColorSpace
dataclass
¶
A small, explicit display-referred color descriptor for a frame/stream.
Mirrors the WebCodecs VideoColorSpace fields the browser consumes directly
(primaries gamut, transfer function, matrix RGB-vs-YUV coupling) plus a
full_range flag and a bit_depth for the HDR future. Two presets ship as
first-class — :data:SRGB (the implicit default) and :data:DISPLAY_P3 (Apple
wide-gamut SDR, 8-bit); bt2020/pq/hlg are expressible (HDR is designed-for)
but not yet wired through a 10-bit pipeline.
The library tags color; it does not convert. The upstream renderer is responsible for producing pixels already in the declared space.
Source code in src/pdum/rfb/types.py
DownscaleHint
dataclass
¶
A server-driven adaptive resolution hint delivered through poll_events().
In the push model the publisher owns the framebuffer size, so the adaptive
controller cannot resize the stream itself — its deepest congestion lever is to
suggest a smaller render resolution and let the render loop honor it. Enabled by
serve(adaptive=True), the controller emits one of these under sustained pressure
(bitrate, fps, and in-flight already at their floors) and again on recovery (with
scale climbing back toward 1.0); the :class:~pdum.rfb.display.Display fans
the aggregate across viewers (the most-congested viewer wins) into the same queue
:meth:~pdum.rfb.display.Display.poll_events drains, tagged as this distinct type so
it is easy to tell apart from an :class:InputEvent::
for ev in display.poll_events():
if isinstance(ev, rfb.DownscaleHint):
w, h = ev.width, ev.height # render/publish at this size
else:
state = update(state, ev.event) # ordinary input event
Honoring the hint is opt-in: publish a frame of the suggested size (the per-viewer
encoder rebuilds and the browser re-configure()s exactly as for any other
resize) or ignore it entirely — the stream keeps working either way.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
scale
|
float
|
The recommended render scale relative to full resolution, in |
required |
width
|
int
|
A convenience suggested size — the display's initial (base) dimensions scaled
by |
required |
height
|
int
|
A convenience suggested size — the display's initial (base) dimensions scaled
by |
required |
received_us
|
int
|
Monotonic microseconds (relative to the display's start) when the hint was emitted. |
required |
Source code in src/pdum/rfb/types.py
EncodedPayload
dataclass
¶
A single encoded payload ready to be put on the wire.
One image is one payload (always a keyframe). One encoded video access unit
is one payload; keyframe marks IDR access units.
Source code in src/pdum/rfb/types.py
EncoderBackend
¶
Bases: Protocol
Turns raw frames into encoded payloads.
Source code in src/pdum/rfb/types.py
FrameSource
¶
Bases: Protocol
Produces raw frames and consumes user-input events.
Internal SPI: the session pulls frames through this shape. Applications no
longer implement it directly — they push frames via
:class:~pdum.rfb.display.Display.
Source code in src/pdum/rfb/types.py
handle_event(event)
async
¶
InputEvent
dataclass
¶
A normalized user-input event delivered to the application.
Produced by :class:~pdum.rfb.display.Display as it fans connected clients'
input into a single stream the application drains with
:meth:~pdum.rfb.display.Display.poll_events.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
client_id
|
str
|
Opaque per-connection identifier, so several viewers on one display can be told apart (e.g. for multi-user coordination). |
required |
principal
|
Any | None
|
Whatever the |
required |
event
|
EventDict
|
The raw normalized event dict ( |
required |
received_us
|
int
|
Monotonic microseconds (relative to the display's start) when the event was received. |
required |
Source code in src/pdum/rfb/types.py
RawFrame
dataclass
¶
A single raw frame produced by a :class:FrameSource.
Parameters:
| Name | Type | Description | Default |
|---|---|---|---|
seq
|
int
|
Monotonically increasing frame sequence number. |
required |
width
|
int
|
Frame dimensions in pixels. |
required |
height
|
int
|
Frame dimensions in pixels. |
required |
timestamp_us
|
int
|
Capture/render timestamp in microseconds. |
required |
pixel_format
|
PixelFormat
|
Layout of |
required |
memory
|
MemoryKind
|
Where |
required |
data
|
Any
|
The pixel payload. For CPU frames this is a |
required |
pixel_ratio
|
float
|
Render-side device-pixels-per-logical-pixel of this frame (default |
1.0
|
color
|
dict | None
|
Optional color descriptor ( |
None
|