Streaming H.264 Remote-Desktop Video over a WebSocket and Decoding It with WebCodecs
This post documents the pipeline ET Ducky uses to deliver H.264 remote-desktop video to a browser without WebRTC. A capture helper on the Linux host encodes H.264, the agent relays Annex-B NALUs over a WebSocket with a small binary framing protocol, and the dashboard decodes with the WebCodecs VideoDecoder and paints VideoFrame objects to a canvas. It covers the wire protocol, the SPS/PPS handling that WebCodecs configuration requires, keyframe recovery, the JPEG fallback, and the bandwidth failure mode that led to the move to H.264.
Why the pipeline does not use WebRTC
WebRTC is the common choice for real-time video in a browser. It does not fit this case. The remote host is an unattended endpoint behind arbitrary NAT, and sessions relay through the same authenticated WebSocket infrastructure the agent already maintains, with no STUN/TURN deployment, no SDP negotiation, and no ICE state machine. WebRTC would add congestion-controlled UDP transport, which matters less for desktop content at 12 to 30 fps than it does for camera video. It would also add a second transport stack and a second security surface. WebCodecs removed the need for it. The browser exposes the hardware H.264 decoder directly, so a plain WebSocket carrying NALUs plus VideoDecoder gives hardware-accelerated playback over the transport the agent already uses.
The bandwidth failure that led to H.264
The first-generation pipeline sent JPEG frames. At quality 75, 1718×820, and 20 fps, the stream ran 5 to 8 MB/s, which was enough to saturate a typical office uplink. The failure did not present as visible lag. Sessions ended at exactly 30 seconds. The chain was: the socket's send buffer filled, ws.SendAsync stalled, the stalled client could not dispatch its 30-second keep-alive ping, the server saw an idle channel and closed it, and the helper tore down. The 30-second signature in the journal pointed at the keep-alive timer that the stalled send had starved.
Two mitigations shipped. JPEG quality dropped to 50 and the frame cap to 12 fps, which roughly halved bandwidth with acceptable quality for desktop text. H.264 then became the default. Encoded desktop content with inter-frame deltas runs about 250 to 500 KB/s at 1080p, 30 fps, and a 2 Mbps target bitrate, an order of magnitude below the JPEG stream.
Encoder side
The Linux capture helper encodes with whichever H.264 element is available, preferring hardware (vah264enc, VAAPI) and falling back to software (x264enc). The agent launches it with --codec h264 --max-fps 30 --bitrate 2000 --keyframe-interval 120. Passing --codec h264 explicitly turns a missing encoder into a visible failure. If neither encoder exists on the host, the helper exits with an error instead of degrading to JPEG. On production hosts a codec change is an operator decision, and a silent fallback would surface later in a bandwidth graph.
Wire protocol
The helper-to-agent framing distinguishes three payload types: JPEG full frames (type 1), H.264 NALUs (type 2, Annex-B start-code prefixed, with a keyframe bit in a flags byte), and an H.264 config payload (type 4) carrying SPS+PPS, emitted once at session start. The agent relays to the browser using the dashboard's existing frame protocol, extended with two fields:
type=6 codec announce: byte 0 = 6 byte 1 = codec (1 = JPEG, 2 = H.264) bytes 2-3 = width (LE u16) bytes 4-5 = height (LE u16) bytes 6-9 = reserved bytes 10+ = SPS+PPS (Annex-B; H.264 only) type=1 frame: byte 0 = 1 bytes 1-2 = width bytes 3-4 = height bytes 5-8 = x, y byte 9 = flags (bit 0 = keyframe) byte 10 = codec (1 = JPEG, 2 = H.264) bytes 11-14 = sequence bytes 15+ = NALU or JPEG bytes
Ordering is a hard requirement: the codec announce must reach the browser before the first NALU frame, because the decoder cannot be configured without the parameter sets. The agent tracks whether the announce has been sent and holds frames until it has. The JPEG path never produces a config payload, so the agent synthesizes a codec announce lazily on the first JPEG frame. The browser then routes frames to the legacy image decoder instead of waiting for SPS/PPS that will never arrive.
WebCodecs specifics on the decoder side
On the dashboard, the H.264 path is a sidecar module rather than an edit to the existing renderer. It wraps WebSocket at the prototype level, inspects binary messages before the JPEG renderer's onmessage sees them, decodes type-2 frames itself, and calls stopImmediatePropagation() so the JPEG path never attempts to decode an H.264 NALU. The reason is operational. The renderer ships minified, and patching a documented interception point is more maintainable than pattern-matching changes into a minified bundle. The same technique is used for the multi-display fix described at the end of this post.
The WebCodecs details that cost time:
- Annex-B versus AVCC.
VideoDecoder.configure()with adescriptionfield puts the decoder in AVCC mode, expecting length-prefixed NALUs. Feeding Annex-B start-code data in that mode fails. With start-code-prefixed NALUs, the parameter sets travel in-band and configuration omitsdescription. The SPS/PPS from the codec announce are used to derive the codec string, then prepended to the first chunk. - Keyframe discipline. Every
EncodedVideoChunkmust be labeledkeyordeltacorrectly, which is what the flags byte in the frame header is for. Mislabeling a delta as key produces decoder errors or corruption. - Recovery. On decoder error or WebSocket reconnect, the sidecar resets the decoder and waits for the next keyframe rather than feeding deltas into a fresh decoder. The 120-frame keyframe interval bounds the worst-case recovery window at four seconds of 30 fps video.
- Feature detection. If
window.VideoDecoderis absent orconfigure()throws, the sidecar uninstalls itself and the JPEG path continues to work. The agent should never have announced H.264 to a browser without WebCodecs, so this path covers the case where the availability heuristic is wrong.
Multi-display switching and the renderer buffer size
A related renderer bug is recorded here because its symptom did not point at the cause. Switching the captured display appeared to do nothing. The agent did re-capture from the new monitor and frames arrived, but the screen did not change. The renderer's offscreen buffer had been sized once, to the primary display, and never resized. New frames from a different-geometry display were drawn into a wrong-sized buffer, clipped and composited over the stale image. The fix was for the agent to broadcast fresh display info including the active display index after a switch, and for the frontend to size the renderer to the active display's dimensions instead of the primary's. In this pipeline, an unchanged screen after a display switch meant the frame data had changed and a buffer between the data and the pixels had not.
What this pipeline does not do
There is no audio, no congestion-controlled transport, and no bandwidth adaptation beyond the encoder's fixed target bitrate; a link that cannot sustain roughly 500 KB/s will fall behind rather than degrade quality. Cursor rendering, clipboard synchronization, and input injection are separate channels in the same session protocol and are unaffected by codec choice. Windows hosts use a different capture path (DXGI/GDI via a signed helper, covered in the secure-desktop post); the pipeline described here is the Linux path end to end.