Mapping WebVTT Cues to Broadcast Timelines
A WebVTT file delivered for linear playout or OTT-to-broadcast repackaging fails QC not because its syntax is invalid but because its timebase is wrong for the destination. WebVTT timestamps are continuous HH:MM:SS.mmm floats (W3C WebVTT, §6.3), while broadcast infrastructure runs on discrete, frame-locked SMPTE ST 12-1 timecode. At 29.97 fps a single frame is 1000 / (30000/1001) ≈ 33.3667 ms, so any cue edge that does not land on a frame boundary is rounded by the muxer — and naive rounding accumulates. A cue ending at 01:00:00.125 is roughly one third of a frame past the grid; repeat that bias across the thousands of cues in a two-hour program and the caption stream drifts past the ±2 frame (±66.7 ms) synchronization tolerance of FCC 47 CFR § 79.1 before the third act. The job of this page is the deterministic mapping layer that takes the program’s X-TIMESTAMP-MAP base offset, snaps every cue edge to the nearest 29.97 drop-frame boundary, and records the residual error so QC can quarantine off-grid cues before they reach playout.
Minimal working implementation
from pathlib import Path
from fractions import Fraction
from dataclasses import dataclass
from typing import Iterator
import webvtt # webvtt-py 0.4.6+
# SMPTE ST 12-1 — 29.97 fps drop-frame constants
NTSC_RATE = Fraction(30000, 1001) # exact 29.97 fps, not the 29.97 float
FRAMES_PER_10MIN = 17982 # 10 nominal minutes minus 18 dropped frames
FRAMES_PER_MIN = 1798 # 1 nominal minute minus 2 dropped frames
FRAME_MS = float(1000 / NTSC_RATE) # ≈ 33.3667 ms per frame
def base_offset_ms(path: Path) -> float:
"""X-TIMESTAMP-MAP MPEGTS (90 kHz PTS) -> ms offset; HLS WebVTT, RFC 8216 §3.5."""
for line in path.read_text(encoding="utf-8-sig").splitlines()[:15]:
if line.startswith("X-TIMESTAMP-MAP="):
pairs = dict(p.split(":", 1) for p in line.split("=", 1)[1].split(","))
return int(pairs.get("MPEGTS", "0")) / 90.0 # 90 kHz tick -> ms
return 0.0
def ms_to_df_timecode(total_ms: float) -> str:
"""Milliseconds -> 29.97 drop-frame timecode HH:MM:SS;FF (SMPTE ST 12-1)."""
f = round(max(total_ms, 0.0) * NTSC_RATE / 1000) # nearest integer frame
d, m = divmod(f, FRAMES_PER_10MIN)
# re-insert the frames SMPTE drops so the FF field reads as a wall-clock label
f += 18 * d + 2 * ((m - 2) // FRAMES_PER_MIN) if m > 1 else 18 * d
return f"{f//108000%24:02d}:{f//1800%60:02d}:{f//30%60:02d};{f%30:02d}"
@dataclass(frozen=True)
class BroadcastCue:
index: int
start_tc: str # drop-frame timecode, semicolon separator
end_tc: str
drift_ms: float # worst per-edge quantization error
in_tolerance: bool # FCC 47 CFR § 79.1 — within ±2 frames
text: str
def map_cues_to_timeline(path: Path) -> Iterator[BroadcastCue]:
"""Map WebVTT cues onto a 29.97 DF broadcast timeline with a per-cue drift audit."""
offset = base_offset_ms(path)
for i, cue in enumerate(webvtt.read(str(path))):
start = cue.start_in_seconds * 1000 + offset
end = cue.end_in_seconds * 1000 + offset
# quantization error = distance from each edge to its snapped frame boundary
drift = max(abs(round(start / FRAME_MS) * FRAME_MS - start),
abs(round(end / FRAME_MS) * FRAME_MS - end))
yield BroadcastCue(
index=i,
start_tc=ms_to_df_timecode(start),
end_tc=ms_to_df_timecode(end),
drift_ms=round(drift, 3),
in_tolerance=drift <= 2 * FRAME_MS, # FCC § 79.1 ±2-frame budget
text=cue.text.replace("\n", " "),
)
Code walkthrough
Exact rate, not the 29.97 float. NTSC_RATE = Fraction(30000, 1001) is the only rate constant in the module. The decimal 29.97 is short by 0.0003 fps, and that tiny error, multiplied by program-length frame counts, is itself enough to manufacture a frame of drift over a feature. Using Fraction keeps the frame math exact until the single round() that lands a cue on the grid.
base_offset_ms reads the header, not the cues. HLS-segmented WebVTT (RFC 8216, §3.5) carries an X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000 line that ties VTT-local time to the 90 kHz MPEG-TS presentation clock. 900000 / 90 = 10000 ms, i.e. the segment actually starts ten seconds into the program. Parsers that ignore this header treat every cue as zero-relative, so a mid-roll segment’s captions land at 00:00:00 instead of 00:00:10 and the whole block is wrong by the offset. The function reads only the first 15 lines because the header block precedes the first cue, and uses utf-8-sig so a leading BOM never poisons the startswith test.
ms_to_df_timecode is the David Heidelberger drop-frame algorithm. SMPTE drop-frame does not drop picture frames — it skips two labels (;00 and ;01) at the top of every minute except every tenth, so the timecode clock tracks the 29.97 wall clock to within a frame over a day. The function first computes the linear frame index, then re-inserts the skipped labels: 18 per complete 10-minute block plus 2 for each whole minute past the first inside the current block. The m > 1 guard is what protects the ten-minute boundary itself from a spurious skip. The semicolon separator in the output is mandatory — SMPTE ST 12-1 uses ; to denote drop-frame and : for non-drop, and downstream SCC or IMSC writers branch on exactly that character.
The drift audit is the compliance payload. Snapping to a frame grid can move an edge by at most half a frame (±16.68 ms), but the audit measures the real distance from each edge to its boundary and keeps the worse of the start/end pair. in_tolerance compares that against the full ±2 frame budget of FCC § 79.1, so the caller can route any cue that exceeds it to quarantine rather than discovering the failure in a downstream QC report. Continuous, audio-referenced measurement is a separate concern handled by automated sync drift detection; this audit is the cheap static check at the mapping boundary.
The result is an iterator of immutable BroadcastCue records — no list is materialized, so the same function streams a 4-cue clip or a 40,000-cue archive at flat memory.
Threshold reference table
| Parameter | Value | Source |
|---|---|---|
| Frame duration @ 29.97 fps | 1000 / (30000/1001) ≈ 33.3667 ms |
SMPTE ST 12-1 |
| Drop-frame skip | 2 labels/min, except every 10th min | SMPTE ST 12-1 |
| Frames re-inserted per 10-min block | 18 | SMPTE ST 12-1 |
| Max quantization error (snap to grid) | ±0.5 frame ≈ ±16.68 ms | derived |
| Sync tolerance | ±2 frames ≈ ±66.7 ms | FCC 47 CFR § 79.1 |
X-TIMESTAMP-MAP clock |
90,000 ticks/sec (90 kHz) | RFC 8216 §3.5 |
| Drop-frame separator | ; (semicolon) |
SMPTE ST 12-1 |
Edge cases & known gotchas
- Non-drop and other rates. This module is NTSC drop-frame only. A true 30 NDF, 25 fps (PAL), or 24/23.976 fps source needs a different rate constant and an empty drop table — applying drop-frame correction to a non-drop timeline manufactures the exact error it is meant to remove. Gate the mapper on the declared project rate and reject unsupported ones.
- Missing
X-TIMESTAMP-MAP. A flat (non-segmented).vttexported from an NLE has no header line, sobase_offset_msreturns0.0and the cues are mapped program-relative. That is correct for a zero-based asset but wrong for a segment expected to start mid-program — confirm the destination’s base offset rather than assuming zero. LOCAL≠00:00:00.000. When theLOCALfield of the header is non-zero, the true offset isMPEGTS/90 − LOCAL_ms; the trimmed parser above assumes a zeroLOCAL. Parse and subtract it before mapping if your packager emits a non-zero local anchor.- Cue onset regression. An edit that pushes a later cue’s start before an earlier cue’s, or a timeline that wraps past
23:59:59;29, yields descending or rolled-over timecodes. Assert monotonic onsets across the stream and quarantine the asset rather than emitting a backwards timeline. - BOM and
utf-8-sig. A BOM-prefixed file decoded as strict UTF-8 leaves aU+FEFFglyph in front of theWEBVTTmagic and breaks header detection; reading withutf-8-sig(as above) strips it. The same sanitation is covered in depth by the parent WebVTT cue extraction & validation step.
Where this plugs in
This mapper is the timeline-placement stage of WebVTT cue extraction & validation: that upstream stage’s sanitizer and validators first reduce a loose .vtt to a uniform {index, start_ms, end_ms, text} model, and this code takes that model, applies the program offset, and quantizes it onto the destination frame grid immediately before mux. The BroadcastCue records feed the SCC/IMSC writer for playout, and the in_tolerance flag feeds the quarantine route. At archive scale, fan the per-file mapping out with the queue and backpressure patterns in async batch caption processing, which already enforces the same ±2-frame and reading-rate budgets across a worker pool.
Related
- WebVTT cue extraction & validation — Parent reference: produces the normalized cue model this mapper places onto a frame grid.
- Converting SCC to SRT without timing loss — The mirror-image problem: turning frame-counted timecode back into milliseconds with the same drop-frame compensation.
- SCC vs SRT vs WebVTT architecture — Why the millisecond and frame-count timebases diverge, and when mapping between them is the right move.
- FCC Part 79 compliance checklist — The ±2-frame synchronization clause this drift audit enforces.
Part of: SRT, SCC & WebVTT Parsing Workflows — the broadcast caption parsing reference.