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.

Quantizing a WebVTT cue edge onto a 29.97 fps drop-frame grid Three steps for one cue edge. Step 1 adds the X-TIMESTAMP-MAP offset (MPEGTS 900000 ÷ 90 = +10 000 ms), turning a VTT-local end of 00:00:00.125 into program time 00:00:10.125. Step 2 snaps the off-grid edge at 125.000 ms to the nearest 33.3667 ms frame boundary (frame 4, 133.47 ms), leaving a residual drift of ≈ 8.47 ms (≈ ¼ frame). Step 3 audits that residual against the FCC §79.1 ±2-frame (±66.7 ms) band shaded around the target: 8.47 ms is well inside, so in_tolerance = True. Quantizing a WebVTT cue edge onto a 29.97 fps drop-frame grid offset → snap to nearest frame → audit residual against the FCC §79.1 ±2-frame budget 1 · Apply the X-TIMESTAMP-MAP offset WebVTT-local cue end 00:00:00.125 + MPEGTS 900000 ÷ 90 = +10 000 ms program-timeline cue end 00:00:10.125 2 · Snap each edge to the nearest frame (round to integer frame) ms → 100.10 133.47 166.83 200.20 frame 3 frame 4 frame 5 frame 6 33.3667 ms = 1 frame cue edge 125.000 ms (off-grid) snap → drift_ms ≈ 8.47 ms (~¼ frame) 3 · Audit the residual against the FCC §79.1 ±2-frame band −2 frames +2 frames −66.7 ms +66.7 ms snapped frame (target) 8.47 ms in_tolerance = True — residual 8.47 ms ≪ 66.7 ms budget

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) .vtt exported from an NLE has no header line, so base_offset_ms returns 0.0 and 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.
  • LOCAL00:00:00.000. When the LOCAL field of the header is non-zero, the true offset is MPEGTS/90 − LOCAL_ms; the trimmed parser above assumes a zero LOCAL. 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 a U+FEFF glyph in front of the WEBVTT magic and breaks header detection; reading with utf-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.

Part of: SRT, SCC & WebVTT Parsing Workflows — the broadcast caption parsing reference.