02. Video Streaming / OTT — Low-Level Design¶
~18 min read · Part 3 of 4 (Overview → HLD → LLD → Q&A)
Data models¶
CREATE TABLE title (
id BIGINT PRIMARY KEY,
name TEXT, duration_sec INT,
status SMALLINT -- ingesting / transcoding / ready
);
CREATE TABLE rendition (
title_id BIGINT, resolution TEXT, bitrate_kbps INT, codec TEXT,
segment_cnt INT, manifest_key TEXT, -- blob key of this rendition's segment list
PRIMARY KEY (title_id, resolution, codec)
);
CREATE TABLE transcode_job (
title_id BIGINT, segment_no INT, rendition TEXT,
status SMALLINT, -- pending / done / failed
PRIMARY KEY (title_id, segment_no, rendition)
);
-- User state (KV): progress:{user_id}:{title_id} = position_sec (last write wins)
-- Segments in blob: {title_id}/{rendition}/seg_{n}.ts (immutable)
transcode_job keyed by (title_id, segment_no, rendition) makes each encode unit independently trackable and idempotently retryable — a failed chunk re-runs alone. progress is deliberately last-write-wins: a resume point a few seconds stale is invisible to the viewer.
Component internals¶
Transcoding pipeline — chunk-level parallelism¶
def ingest(title_id, master):
segments = segmenter.split(master, target_sec=6) # ~6s GOP-aligned chunks
for n, seg in enumerate(segments):
for r in RENDITIONS: # 240p…4K × codecs
job_queue.put(TranscodeJob(title_id, n, r, seg_ref(seg)))
# jobs run in parallel across the worker fleet
def worker(job):
if jobs.status(job) == "done": # idempotent
return
out = ffmpeg_encode(job.seg_ref, job.rendition) # one chunk, one rendition
blob.put(segment_key(job.title_id, job.rendition, job.segment_no), out)
jobs.mark_done(job)
if all_done(job.title_id):
write_manifests(job.title_id) # assemble per-rendition manifests
Splitting on GOP-aligned boundaries lets each ~6s chunk encode independently, so a 60-minute title (~600 chunks) × 10 renditions = 6,000 units spread across the fleet. Wall-clock time is the slowest single chunk, not the sum — minutes instead of hours.
Manifest — the menu the client orders from¶
# Master manifest (HLS-style) lists renditions:
#EXT-X-STREAM-INF:BANDWIDTH=5000000,RESOLUTION=1920x1080 1080p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=1200000,RESOLUTION=854x480 480p/index.m3u8
#EXT-X-STREAM-INF:BANDWIDTH=400000,RESOLUTION=426x240 240p/index.m3u8
# Per-rendition manifest lists immutable segment URLs:
#EXTINF:6.0 seg_0.ts
#EXTINF:6.0 seg_1.ts ...
The server ships this static menu; it holds no per-viewer streaming state. Every viewer of the title gets the same manifest and the same immutable segment URLs — which is exactly why segments cache perfectly at the edge.
ABR — client-side rendition selection¶
# runs in the player, per segment
def pick_rendition(measured_kbps, buffer_sec):
safe = measured_kbps * 0.8 # headroom against variance
choice = highest_rendition_below(safe)
if buffer_sec < LOW_WATER: # buffer draining → be conservative
choice = step_down(choice)
elif buffer_sec > HIGH_WATER: # healthy buffer → try up
choice = step_up(choice)
return choice
The player owns adaptation because the bandwidth signal lives at the player. When our 8pm viewer's wifi drops from 8 Mbps to 1 Mbps mid-episode, the next segment fetch is 480p instead of 1080p — quality dips for a few seconds, but playback never stalls.
Core algorithm — pre-warming the edge for a release¶
before release T:
predict top-K titles (the new season)
for each edge in CDN:
for rendition in POPULAR_RENDITIONS: # e.g. 1080p, 480p first
push first M segments of each title # enough to cover startup + early play
verify edge cache-hit readiness before T
Pre-warming turns a thundering herd into cache hits. At 8pm, 10M players request seg_0 of the new episode; because the first segments are already at every edge, origin serves ~0 of them. Without pre-warm, 10M cold-cache misses would stampede origin — the herd, not the total bytes, is what would break it.
Sequence diagram — playback with a bandwidth dip¶
Player Playback API Edge/CDN Origin
│ GET manifest │ │ │
├──────────────▶│ (entitlement) │ │
│◀── renditions─┤ │ │
│ pick 1080p │ │ │
├─ GET 1080p/seg_0 ──────────────▶│ (hit, pre-warmed)│
│◀── segment ─────────────────── │ │
│ wifi drops → pick 480p │ │
├─ GET 480p/seg_5 ───────────────▶│ (miss) ─────────▶│ fill
│◀── segment (no rebuffer) ─────── │◀─────────────────┤
Concurrency and edge cases¶
- Chunk encode retry: idempotent per
(title, segment, rendition); a failed chunk re-runs without touching completed ones. - Manifest published before all chunks done: publish only after
all_done, or publish progressively (live-style) — for VOD, wait for completeness so the player never requests a missing segment. - Startup rendition guess: start conservative (fast start) then ramp up, so the first 2 seconds are reliable rather than optimistic.
- Thundering herd on a cold segment: edge request coalescing sends one origin fill per segment per edge; concurrent viewers wait for that fill.
- Resume-position races across devices: last-write-wins; a few seconds' discrepancy between phone and TV is acceptable.
- DRM: segments are encrypted; the player fetches a license (per-title key) separately, so caching still works on the encrypted bytes.
- Codec/device mismatch: the manifest offers multiple codecs; the player selects one its hardware decodes, falling back to H.264 universally.