Decoding an H.264 Remote-Desktop Stream in the Browser 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 motivated the move to H.264 in the first place.
Why not WebRTC
WebRTC is the default answer for real-time video in a browser, and it is the wrong tool for this job. The remote host is an unattended endpoint behind arbitrary NAT; 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. What WebRTC would buy — congestion-controlled UDP transport — matters less for desktop content at 12 to 30 fps than it does for camera video. What it costs is a second transport stack and a second security surface. WebCodecs changed the calculus: the browser exposes the hardware H.264 decoder directly, so a plain WebSocket carrying NALUs plus VideoDecoder gives hardware-accelerated playback with the transport you already trust.
The bandwidth failure that motivated the codec
The first-generation pipeline sent JPEG frames. At quality 75, 1718×820, and 20 fps, the stream ran 5–8 MB/s — enough to saturate a typical office uplink. The interesting part is how the failure presented: not as visible lag, but as sessions dying 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 "exactly 30s" signature in the journal was the tell — a timeout constant appearing in a failure is a strong hint the failure is starvation of whatever that timer protects, not the thing the timer measures.
Two mitigations shipped: JPEG quality dropped to 50 and the frame cap to 12 fps (roughly halving bandwidth with acceptable quality for desktop text), and then H.264 became the default. Encoded desktop content with inter-frame deltas runs about 250–500 KB/s at 1080p, 30 fps, 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 is a deliberate fail-loud choice: if neither encoder exists on the host, the helper exits with an error instead of silently degrading to JPEG. On production hosts a codec change should be an operator decision, not a silent fallback discovered 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 knows to route frames to the legacy image decoder rather than waiting for SPS/PPS that will never arrive.
Decoder side: WebCodecs specifics
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 rather than aesthetic: the renderer ships minified, and monkey-patching a documented interception point is more maintainable than pattern-matching changes into a minified bundle. The same technique carries 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 ride 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 labeledkeyordeltatruthfully — that is what the flags byte in the frame header exists for. Mislabeling a delta as key produces decoder errors or corruption, not a graceful failure. - 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 is belt-and-suspenders for the case where the availability heuristic is wrong.
Multi-display switching: the invisible-bug variant
A related renderer bug is worth recording because its symptom pointed everywhere except the cause. Switching the captured display appeared to do nothing: the agent did re-capture from the new monitor, frames arrived, and 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: the agent broadcasts fresh display info including the active display index after a switch, and the frontend sizes the renderer to the active display's dimensions instead of the primary's. The lesson generalizes: in a remote-rendering pipeline, "nothing happened" on screen usually means the data changed and a buffer between the data and the pixels did 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.