§ 5.4  Module 5 — The Codec Wars and the Science of Compression

The Grammar of Video: Frames, GOPs, and Rate Control

By the end of this lesson

  • Explain I/P/B prediction, motion compensation, and GOP structure
  • Justify the real-time B-frame ban via latency analysis
  • Encode real frames with WebCodecs, force keyframes, and analyze GOP structure from chunk types/sizes
  • Implement a naive per-frame bitrate budget and explain CBR/VBR/CQ

Historical note

The mathematics arrived first. In January 1974, Nasir Ahmed, T. Natarajan, and K. R. Rao published the discrete cosine transform, which concentrates a block of pixels into a handful of significant coefficients. In 1979, Arun Netravali and John Robbins at Bell Labs supplied the other half — motion-compensated prediction, coding a television frame as a displacement of the previous frame plus a small correction. The ITU's H.261 (1988), developed by the specialists group Sakae Okubo chaired for p×64 kbps ISDN lines, fused the two into the hybrid coder — prediction plus transform plus quantization — that every video codec since has elaborated. H.261 knew only intra and forward-predicted frames: a videophone cannot wait. It was MPEG, the committee Leonardo Chiariglione and Hiroshi Yasuda founded in 1988, that discovered how much cheaper video becomes when you may also look forward. MPEG-1 (ISO/IEC 11172-2, 1993), aimed at the roughly 1.5 Mbps a CD-ROM could deliver, introduced the B-frame: bidirectional prediction that references both the previous anchor and the next one and interpolates everything between. For a movie pressed onto a Video CD the cost is invisible — encode overnight, let the player buffer. For a phone call the cost is precise and unpayable: referencing the future means waiting for the future, tens of milliseconds of structural delay in a one-way budget of about 150. Every encoder WebRTC drives is MPEG's hybrid coder with the B-frames amputated. This lesson is about what remains, and what the amputation buys.

5.4.1Most of a frame is the previous frame

Start with the arithmetic of the problem. A 1920 × 1080 frame in 8-bit Y′CbCr with 4:2:0 chroma subsampling — the format § 5.3 left us with — is 1.5 bytes per pixel: about 3.1 MB per frame, 746 Mbps at 30 frames per second. A good encoder ships that scene at 2.5 Mbps, a ratio near 300:1. Subsampling bought a factor of two; the remaining factor of 150 comes from a single observation: most of a frame is the previous frame, moved slightly. A talking head is a nearly static room with a few thousand pixels of lips and eyelids in motion. A camera pan is the same image shifted eight pixels left. Sending pixels again because 33 ms have elapsed is the waste; the hybrid coder exists to stop it.

The machine works block by block. The frame is cut into macroblocks — 16 × 16 luma samples in H.261's vernacular, which H.264 and VP8 kept. For each block, motion estimation searches a window of the previously reconstructed frame for the best matching 16 × 16 patch; the offset to that patch is the motion vector, typically at half- or quarter-pixel precision. Motion compensation then builds a predicted frame by pasting each best match at its block's position, and the encoder subtracts prediction from reality. What is left — the residual — is mostly noise-level texture the prediction missed.

The residual is where Ahmed's transform earns its keep. Each residual block passes through a DCT (8 × 8 in MPEG-1, 4 × 4 in VP8 and much of H.264), which turns spatial samples into frequency coefficients: a flat patch becomes one significant number and a field of near-zeros. Quantization then divides every coefficient by a step size and rounds — the sole irreversible act in the pipeline, and therefore the codec's only quality knob. A larger step manufactures more zeros; long zero runs cost almost nothing after entropy coding. Everything a rate controller does, it does by moving that quantizer step up and down. The bitstream that leaves is just motion vectors plus entropy-coded quantized residuals — a set of instructions for editing the previous frame into this one.

5.4.2I, P, B, and the grammar of the GOP

Three frame types fall out of the design. An I-frame (intra, a keyframe) is coded entirely from itself, like a JPEG: no motion vectors, no dependencies, decodable in isolation. A P-frame predicts from an earlier decoded frame and carries motion vectors plus residuals. A B-frame predicts bidirectionally — from an earlier anchor and a later one — and is the cheapest of the three, because interpolating between two references leaves the smallest residual. The repeating sentence built from these words is the GOP, the group of pictures: one I-frame followed by a pattern of P- and B-frames until the next I-frame. Broadcast MPEG-2 (1995) settled on patterns like I B B P B B P … with a keyframe every 12–15 frames; DVDs and digital television still speak roughly that sentence.

Now the latency analysis the opener promised. B-frames reorder time. In display order I₀ B₁ B₂ P₃, frame B₁ references P₃ — a frame that, at capture time, does not exist yet. The encoder must hold B₁ and B₂ until P₃ arrives, encode P₃ first, and transmit in decode order I₀ P₃ B₁ B₂. At 30 fps, B₁ waits two frame intervals — about 67 ms — before it can even be encoded; the receiver must then hold P₃ in a reorder buffer (a structural delay separate from, and added to, the network jitter buffer) before display. ITU-T G.114 advises keeping one-way mouth-to-ear delay under 150 ms for comfortable conversation, and that budget must also cover capture, encoding, the network, decoding, and rendering. A three-frame reorder spends nearly half of it before the first packet leaves the machine. Offline encoders happily buffer far more: libvpx's quality modes use a lag-in-frames lookahead of up to 25 frames — over 800 ms at 30 fps — to plan allocations.

Real-time systems therefore ban the B-frame outright, and the ban is written into the standards' choice of profiles. H.264's Constrained Baseline profile — the profile RFC 7742 makes mandatory for WebRTC — contains no B-slices at all. VP8 (RFC 6386, November 2011) goes further: the bitstream simply defines no bidirectional prediction. Its extra reference frames — the golden frame and altref — point only backward in decode order, and the one libvpx trick that builds an altref from lookahead is disabled the moment you configure the encoder for real time, which forces lag-in-frames to zero. A WebRTC GOP is the dullest sentence in the language: one I, then P, P, P — each frame predicting from the last, capture order equal to transmit order equal to display order, structural delay zero.

(a) broadcast GOP (MPEG-1 style), display order I0 B1 B2 P3 B1 and B2 reference P3 — a frame not yet captured wire (decode) order: I0 P3 B1 B2 receiver reorders before display (b) real-time GOP — one I, then P forever I0 P1 P2 P3 P4 P5 every reference points to the past — capture order = wire order = display order, structural delay zero
Figure 5.4.1 — The B-frame's latency tax. In (a), B₁ cannot be encoded until P₃ is captured (≈ 67 ms later at 30 fps), and the receiver must reorder. The real-time GOP in (b) has nothing to wait for.
There are no B-frames, and there is no GOPspec

Bidirectionally predicted frames require buffering future frames, so real-time encoders do not emit them; WebRTC video is I and P frames only. There is also no periodic keyframe interval: after the initial IDR, browsers emit keyframes only when asked (PLI/FIR) or on a resolution change. So a "GOP length" does not exist to tune, and a client joining an ongoing stream has nothing to decode until someone requests an IDR. Legacy MCUs that do send B-frames will decode in Chrome but confuse frame timing.

What to do

If you write an SFU, cache the most recent keyframe per layer and send a PLI upstream the instant a subscriber attaches. Do not attempt to force periodic keyframes as insurance — they are expensive (often 10x a P-frame) and they collide with the bandwidth estimator's ramp.

5.4.3The keyframe: salvation and bandwidth bomb

The all-P diet has a structural weakness: every frame depends on the one before it. The keyframe exists to break that chain, and it is needed in exactly two situations. First, entry: a decoder that joins mid-stream has no previous frame to edit, so nothing decodes until an I-frame arrives. Second, repair: lose a packet and the damaged frame is wrong, and so is every frame that predicts from it — the error propagates, smearing artifacts forward indefinitely, until a keyframe resets the reference chain.

Salvation has a price. An I-frame spends bits on every block with no help from prediction; for the same picture at the same quality it typically costs 3–10× the bytes of a delta frame — you will measure the ratio on a live encoder in Problem 1. That price dictates opposite policies in the two halves of the video world. Streaming systems buy keyframes on a timer: HLS and DASH cut the stream into segments a few seconds long, and each segment must begin with a keyframe so that any segment is independently decodable and viewers can join, seek, or switch renditions at every boundary. Their latency budget — seconds — absorbs the cost invisibly.

WebRTC refuses the timer. Its encoders run an effectively infinite GOP: one keyframe at the start of the stream and then deltas forever, with fresh keyframes produced on demand. The demand arrives over the RTCP feedback channel: a receiver whose reference chain broke sends a Picture Loss Indication (PLI), a conferencing server sends a Full Intra Request (FIR), and an SFU asks the sender for a keyframe whenever a new subscriber needs a point of entry into an existing stream. Nothing about the wall clock triggers any of this; a stable one-to-one call can run for an hour on its opening keyframe.

The refusal is not thrift alone — it is self-defense, because a keyframe is a bandwidth bomb. At 1 Mbps and 30 fps, the average frame budget is about 4,167 bytes; a 31 KB keyframe is seven and a half frames of budget detonating in one frame interval. It leaves the encoder as a burst of RTP packets, momentarily driving the send rate far above target; queues build at the bottleneck, delay spikes, the receiver's jitter buffer stretches, and — in the ugliest feedback loop in conferencing — the resulting loss makes other receivers send PLIs, demanding more keyframes: a keyframe storm. Production SFUs throttle PLIs per sender and fan one fresh keyframe out to many requesters precisely to dampen this loop. You can watch every actor in this drama in webrtc-internals; Problem 2 asks you to predict the spike's size with arithmetic first.

per-frame budget ≈ 4.2 KB (1 Mbps ÷ 30 ÷ 8) keyframe ≈ 31 KB — 7.5 frames of budget forced keyframe (frame 30) delta frames 0 30 59 frame index → bytes
Figure 5.4.2 — Bytes per encoded chunk for the 60-frame encode of Problem 1. The two keyframes tower an order of magnitude over the deltas; everything between hugs the budget line.

A common error

"WebRTC sends a keyframe every N seconds." It does not — that is streaming's habit, not conferencing's. WebRTC encoders default to an effectively infinite GOP and produce keyframes only on demand: at stream start, on PLI or FIR from a receiver, or when an SFU needs an entry point for a new subscriber. If you see keyframes arriving on a metronome in webrtc-internals (keyFramesEncoded climbing steadily), you are not looking at policy — you are looking at a symptom, usually a lossy path generating rhythmic PLIs. Periodic keyframes in RTC would pay the bandwidth-bomb price on a schedule while buying nothing that on-demand keyframes do not already provide.

Predict before you peek — everything below follows from § 5.4.2 and § 5.4.3.

Check your understanding

Auto-graded check

4 auto-graded questions with an explanation for every wrong answer. Requires JavaScript. (m5-l4-check1)

Interactive minigame

Budget the GOP — frame types against a pipe that does not flex

A 3D instrument you drive yourself, one variable at a time. It needs JavaScript and WebGL, so it is not shown in this static copy of the page.

5.4.4WebCodecs: a microscope for the encoder

Until recently none of this was observable from a web page. Inside RTCPeerConnection the encoder is sealed machinery: a track goes in, RTP comes out, and the GOP structure between them is invisible. WebCodecs unbundled it. Developed in the W3C Media Working Group (editors Paul Adenot of Mozilla, Bernard Aboba of Microsoft, and originally Chris Cunningham of Google) and shipped in Chrome 94 in September 2021, it hands JavaScript direct handles on the browser's codec implementations: VideoEncoder, VideoDecoder, and their audio twins. This course gives WebCodecs its full treatment in § 8.4, where it anchors the unbundled future; here we use it instrumentally, as a microscope pointed at everything this lesson has claimed.

const encoder = new VideoEncoder({
  output: (chunk, metadata) => record(chunk.type, chunk.byteLength),
  error: (e) => console.error(e),
});
encoder.configure({
  codec: 'vp8',
  width: 320, height: 240,
  bitrate: 500_000,           // bits per second — the encoder's contract
  framerate: 30,
  latencyMode: 'realtime',    // no lookahead, no reordering
});
const frame = new VideoFrame(canvas, { timestamp: 0 });
encoder.encode(frame, { keyFrame: true });   // demand an I-frame
frame.close();                // frames wrap scarce memory — release promptly
await encoder.flush();        // drain: resolves when every chunk is out

Listing 5.4.1 — the whole instrument. Each submitted VideoFrame comes back through output as an EncodedVideoChunk.

The API is a callback pipeline, not a promise-per-frame: encode() queues work and returns immediately, and finished chunks arrive through the output callback in decode order. Two details matter for the problems. First, every EncodedVideoChunk declares its place in the grammar: chunk.type is either 'key' or 'delta', and chunk.byteLength is its cost — type and size are the entire GOP structure, observable at last. Second, the encoder is asynchronous and buffered, so a program that returns without await encoder.flush() inspects an empty array; the flush is the point at which all submitted frames have become chunks.

A subscriber with no keyframe shows black while packets pour indeployment

This is the single most common SFU bug. inbound-rtp.packetsReceived climbs, framesReceived may climb, framesDecoded stays at 0 and keyFramesDecoded stays at 0, and the user sees black. It happens whenever the keyframe request path is missing or rate-limited into oblivion — and it is intermittent in testing, because if a keyframe happens to be generated for another reason within a second or two of joining, everything works.

What to do

Alert on keyFramesDecoded === 0 with packetsReceived > 0 after 2 s; that single check catches the whole class. On the client, you cannot request a keyframe from JavaScript, so recovery must come from the server or from a renegotiation.

5.4.5Rate control: three contracts with the channel

An encoder does not choose how good the video looks; it obeys a bit budget, and rate control is the policy that spends it — almost entirely by steering the quantizer step from § 5.4.1. Three contracts cover practice, and WebCodecs names them in its bitrateMode member. CBR ('constant') holds the wire rate steady and lets quality float frame to frame; it is the oldest contract, born with H.261 on ISDN circuits whose p×64 kbps did not flex, and it is real-time's contract today because the target handed down by the congestion controller is a hard ceiling, not a suggestion. VBR ('variable') holds average rate over a longer horizon and spends unevenly — hoarding bits in easy scenes to splurge on hard ones; it is the file-and-VOD contract, at its best in two-pass encodes that read the whole movie before allocating. CQ ('quantizer') pins quality by fixing the quantizer per frame and lets bitrate land wherever content takes it — the archivist's and screen-recorder's contract, unusable on a link with a ceiling.

In WebRTC the CBR target is not even yours to set: the congestion controller measures the network continuously and re-issues the encoder's target, second by second. The belief that "the codec decides the bitrate" is exactly backwards — the network decides, congestion control translates, the encoder obeys. Under that contract the keyframe becomes a debt instrument: the per-frame budget is targetBps ÷ fps ÷ 8 bytes, a 31 KB keyframe borrows seven-plus frames of it at once, and a CBR controller repays by starving the deltas that follow — the familiar post-keyframe blur that sharpens over the next half second. Problem 2 makes you the rate controller.

The last knob is time itself. Encoding is a search — more candidate motion vectors, finer partitions, more lookahead yield fewer bits at equal quality — so every encoder ships a speed ladder, and real time must buy the cheap rungs. WebCodecs compresses the whole ladder into one declaration: latencyMode: 'realtime' tells the implementation to disable lookahead and frame reordering, prefer speed presets that finish within a frame interval, and optimize for a stream that must leave now — the entire argument of this lesson, folded into a configuration field.

Problems for § 5.4Check your understanding

Two problems: put a real encoder under the microscope, then predict its worst habit with arithmetic.

Lab 1

Anatomy of a GOP, live

Graded in the browser against 3 assertions; the editor and harness require JavaScript.

Lab 2

The rate controller's budget

Graded in the browser against 3 assertions; the editor and harness require JavaScript.

Field quirks

What the specifications say, and what the browsers actually do, are two different documents. These are the divergences that cost production teams their weekends.

You do not set the bitrate; you set a ceiling, and degradationPreference decides what suffersapi

The encoder's target comes from the congestion controller. maxBitrate is a cap, not a target, and there is no standard minBitrate. When the estimate drops, degradationPreference decides the sacrifice: maintain-framerate drops resolution, maintain-resolution drops frame rate, balanced does both. The default depends on content hint and browser, so the same code degrades differently for camera and screen, and users report "it goes blurry" or "it goes choppy" for reasons you did not choose.

What to do

Set it explicitly per use case: maintain-resolution for screenshare and slides, maintain-framerate for talking heads and motion. Set maxFramerate in the encoding for screenshare (5–15 is plenty) so the encoder spends bits on pixels.

qualityLimitationReason answers "why is my video bad" and nobody reads itapi

outbound-rtp.qualityLimitationReason is one of none, cpu, bandwidth, or other, and qualityLimitationDurations gives cumulative seconds in each. This turns the most common support ticket from a guess into a lookup: cpu means the encoder cannot keep up (thermal throttling, too many simulcast layers, a software AV1 encoder on a laptop), bandwidth means the estimator is the constraint. qualityLimitationResolutionChanges counts how often the encoder had to drop resolution.

What to do

Report the qualityLimitationDurations breakdown as a share of call duration in your telemetry. It is the highest-signal-per-byte video metric available and it distinguishes "buy better bandwidth" from "reduce encoder load", which are opposite fixes.

The Impossible Call · Module 5, Lesson 4