WebVTT Cue Extraction & Validation

WebVTT is structurally permissive in a way that breaks naive parsers: a single .vtt file can carry a BOM, a NOTE block, STYLE and REGION headers, optional cue identifiers, and inline <c>/<v> markup — none of which a downstream encoder or compliance auditor wants in the text payload. The engineering gap this page closes is the deterministic reduction of that loose syntax to a uniform, frame-checkable cue model and the validation of every cue against hard numeric limits before it reaches mux or playout: a maximum reading speed of 180 WPM live / 150 WPM pre-recorded (≈ 17 characters per second), a temporal overlap tolerance of ≤ 50 ms between consecutive cues, and a structural cap of 32 characters per line × 2 lines per cue. Miss any of those and you ship cue collisions, on-screen flicker, or reading-rate failures that surface as reportable defects in an FCC or Ofcom audit.

This step is one stage in the broader SRT, SCC & WebVTT parsing workflows reference. Where parsing SCC with Python libraries reconstructs CEA-608 state from hex byte-pairs and SRT timestamp normalization quantizes plaintext cues to a frame grid, the WebVTT lane has the opposite problem: too much structure, much of it optional, all of it a place for a malformed file to hide. The validators below read every limit from the threshold reference table so the same code re-points at a different jurisdiction by swapping a config row.

WebVTT cue extraction and validation — ingest, parse, normalize, gate, branch A horizontal pipeline for one .vtt file. Ingest strips the BOM, verifies the WEBVTT magic string and skips NOTE/STYLE/REGION (W3C §4.1). Parse cues reads the identifier, HH:MM:SS.mmm start/end and text lines (W3C §4.3). Normalize converts timestamps to integer milliseconds, yielding a {index, start_ms, end_ms, text} record. Validate gates applies three stateless checks — reading speed ≤ 17 CPS, overlap ≤ 50 ms, and ≤ 32 chars × 2 lines — with syntax from W3C and timing from FCC §79.1 / Ofcom. The verdict branches: PASS routes to timeline mapping / mux; FAIL routes to quarantine with the cue index and violation list. WebVTT cue extraction & validation — one .vtt file, post-ingest to verdict Loose syntax in → uniform millisecond cue model → hard numeric gates → PASS to mux or FAIL to quarantine {index, ms…} 1 · Ingest strip BOM (U+FEFF) verify WEBVTT magic skip NOTE/STYLE/REGION W3C WebVTT §4.1 2 · Parse cues cue identifier HH:MM:SS.mmm start/end text lines W3C WebVTT §4.3 3 · Normalize timestamps → integer ms uniform cue model 4 · Validate gates reading speed ≤ 17 CPS overlap ≤ 50 ms ≤ 32 chars × 2 lines W3C syntax · FCC §79.1 / Ofcom timing PASS FAIL Timeline / mux ROUTE_TO_TIMELINE → playout Quarantine cue index + violation list pass path → mux fail path → quarantine

Pipeline stage & prerequisites

Cue extraction and validation runs at the post-ingest, pre-normalization boundary: after a .vtt asset arrives from an NLE export, an ASR service, or an OTT packager, and before its cues are mapped to a program timeline or transcoded to CEA-608/708 ancillary data. Placing the gate here is what keeps it cheap — a 33-character line caught at ingest is a re-wrap; the same line caught after mux is a re-render of the whole package.

The validators assume nothing about upstream cleanliness — that is the point of the header sanitization step. They produce a uniform {index, start_ms, end_ms, text} record that the next stage consumes without re-checking. Continuous sync measurement against a live audio reference is out of scope here and is handled by automated sync drift detection; this page enforces the static, per-cue acceptance criteria. Reading-rate enforcement shares its threshold config with enforcing character rate limits in QC so the CPS ceiling is defined once.

Required tooling:

Tool / library Version (tested) Role in this stage
webvtt-py 0.4.6+ Parse cues, identifiers, and line/position cue settings
python 3.10+ dataclasses, pattern matching, fractions for exact frame math
numpy 1.26+ Vectorized overlap/gap arithmetic over large cue arrays
charset_normalizer 3.3+ Detect and strip BOM / non-UTF-8 encodings before parse
pytest 8.0+ Threshold regression fixtures for each gate

Step-by-step implementation

The implementation is a four-step pipeline: sanitize the header, parse cues to a normalized model, run the threshold gates statelessly, then fold the result into a routing verdict. Every step is a pure function so quarantined assets can be re-run after a fix without special-casing.

Step 1 — Sanitize the header and verify the magic string

The W3C WebVTT specification requires the file to begin with the string WEBVTT, optionally preceded by a UTF-8 BOM (U+FEFF) and optionally followed by a space/tab and a header label. Vendor exports routinely violate the surrounding rules — a stray BOM counted as content, CRLF line endings, or a NOTE block jammed against the magic string. Detect the encoding and strip the BOM before handing bytes to the parser, or webvtt-py will see WEBVTT and reject the file.

from charset_normalizer import from_bytes

def sanitize_header(path: str) -> str:
    """Return decoded VTT text with BOM stripped and CRLF normalized.

    W3C WebVTT §4.1 — a file MUST begin with an optional U+FEFF BOM
    followed by the ASCII string "WEBVTT".
    """
    raw = open(path, "rb").read()
    best = from_bytes(raw).best()              # detect cp1252/utf-16/utf-8 exports
    text = str(best) if best else raw.decode("utf-8", errors="replace")
    text = text.lstrip("")               # strip BOM counted as content
    text = text.replace("\r\n", "\n").replace("\r", "\n")  # normalize line endings
    first = text.split("\n", 1)[0].strip()
    if not first.startswith("WEBVTT"):         # §4.1 signature gate
        raise ValueError(f"Not a WebVTT file: first line is {first!r}")
    return text

Step 2 — Parse cues to a normalized millisecond model

webvtt-py returns each cue as a Caption object whose start and end are strings in the HH:MM:SS.mmm (or MM:SS.mmm) grammar defined by the spec. Broadcast arithmetic needs a single integer representation, so normalize every timestamp to milliseconds at the parse boundary and never compute on the string form. Parse from the sanitized buffer rather than the file path so Step 1’s cleanup is not bypassed.

import io
from dataclasses import dataclass
import webvtt

@dataclass
class Cue:
    index: int
    start_ms: int
    end_ms: int
    text: str

def _ts_to_ms(value: str) -> int:
    """Normalize a WebVTT timestamp to integer milliseconds.

    W3C WebVTT §4.3 — timestamp is [HH:]MM:SS.mmm; minutes/seconds are
    two digits, fraction is exactly three digits.
    """
    h, m, rest = ("00", *value.split(":"))[-3:]   # tolerate MM:SS.mmm
    s, _, frac = rest.partition(".")
    ms = (frac + "000")[:3]                        # pad/truncate to 3 digits
    return ((int(h) * 60 + int(m)) * 60 + int(s)) * 1000 + int(ms)

def parse_cues(vtt_text: str) -> list[Cue]:
    captions = webvtt.read_buffer(io.StringIO(vtt_text))   # skips NOTE/STYLE/REGION
    cues = []
    for i, cap in enumerate(captions):
        start, end = _ts_to_ms(cap.start), _ts_to_ms(cap.end)
        if end <= start:                            # zero/negative duration is invalid
            raise ValueError(f"cue {i}: end {cap.end} <= start {cap.start}")
        cues.append(Cue(i, start, end, cap.text.strip()))
    return cues

Step 3 — Run the stateless validation gates

Each cue is checked against three independent dimensions: reading speed, structural geometry, and temporal relationship to its neighbour. Keep the function pure — it reads cues and the threshold config, mutates nothing, and returns a violation list. Reading speed is computed in characters per second (the metric CEA-708 decoders and Ofcom both use) and cross-checked against the words-per-minute ceiling.

# Thresholds — see the reference table; sourced from FCC §79.1 / Ofcom / W3C
MAX_CPS            = 17     # Ofcom Annex 1 — ~17 chars/sec sustained reading speed
MAX_WPM            = 180    # FCC §79.1(j) live ceiling; pre-recorded target 150
MAX_CHARS_PER_LINE = 32     # ANSI/CTA-608-E — 32-column caption grid
MAX_LINES_PER_CUE  = 2      # EBU-TT-D / Ofcom — two-line on-screen maximum
MAX_OVERLAP_MS     = 50     # consecutive cues must not overlap beyond decoder flush window
MAX_GAP_MS         = 2000   # flag suspicious silence between cues for review

def validate_cue(cue: Cue, prev: Cue | None) -> list[str]:
    v = []
    dur_s = (cue.end_ms - cue.start_ms) / 1000.0
    chars = len(cue.text.replace("\n", ""))

    cps = chars / dur_s if dur_s > 0 else float("inf")
    if cps > MAX_CPS:                                       # reading-speed gate
        v.append(f"reading speed {cps:.1f} CPS > {MAX_CPS}")
    wpm = (len(cue.text.split()) / dur_s) * 60 if dur_s > 0 else float("inf")
    if wpm > MAX_WPM:
        v.append(f"reading speed {wpm:.0f} WPM > {MAX_WPM}")

    lines = [ln for ln in cue.text.splitlines() if ln.strip()]
    if len(lines) > MAX_LINES_PER_CUE:                     # structural geometry
        v.append(f"{len(lines)} lines > {MAX_LINES_PER_CUE}")
    if any(len(ln) > MAX_CHARS_PER_LINE for ln in lines):
        v.append(f"line width > {MAX_CHARS_PER_LINE} chars")

    if prev is not None:                                   # temporal relationship
        delta = cue.start_ms - prev.end_ms
        if delta < -MAX_OVERLAP_MS:
            v.append(f"overlap {abs(delta)}ms > {MAX_OVERLAP_MS}ms")
        elif delta > MAX_GAP_MS:
            v.append(f"gap {delta}ms > {MAX_GAP_MS}ms")
    return v

Step 4 — Fold cues into a routing verdict

The per-cue results aggregate into one machine-readable record that the media asset management (MAM) system consumes over REST. A single violating cue sets pipeline_action to quarantine and attaches the failing cue index and timecode for the audit trail.

import json

def build_verdict(asset_id: str, cues: list[Cue]) -> str:
    violations, prev = [], None
    for cue in cues:
        cv = validate_cue(cue, prev)
        if cv:
            violations.append({"cue": cue.index,
                               "start_ms": cue.start_ms,
                               "issues": cv})
        prev = cue
    passed = not violations
    return json.dumps({
        "asset_id": asset_id,
        "cue_count": len(cues),
        "compliance_status": "PASS" if passed else "FAIL",
        "violations": violations,
        "pipeline_action": "ROUTE_TO_TIMELINE" if passed else "QUARANTINE",
    }, indent=2)

A PASS verdict hands the normalized cue list to the timeline-alignment stage; the offset correction and frame-accurate rounding that step performs are covered in mapping WebVTT cues to broadcast timelines. When the same cues must be re-emitted as SRT or SCC for archival or 608 carriage, the millisecond model produced here is the input to SRT timestamp normalization, which quantizes the boundaries onto a drop-frame or non-drop-frame grid.

Threshold reference table

Every limit the validators read lives here, not in prose, so a config swap re-points the same code at a different jurisdiction or timebase.

Parameter Value Source clause Notes
Reading speed (sustained) ≤ 17 CPS Ofcom Guidelines on Subtitling, Annex 1 Children’s content tightens toward ≤ 12 CPS
Reading speed (live) ≤ 180 WPM FCC 47 CFR § 79.1(j) Pre-recorded target ≈ 150 WPM
Max characters per line 32 ANSI/CTA-608-E 32-column caption grid width
Max lines per cue 2 EBU-TT-D / Ofcom Three lines only by exception
Cue overlap tolerance ≤ 50 ms Decoder buffer flush window Beyond this causes on-screen flicker
Suspicious gap (review) > 2000 ms Internal QC heuristic Distinguish from genuine silence
Timestamp grammar HH:MM:SS.mmm W3C WebVTT § 4.3 Fraction is exactly 3 digits
File signature WEBVTT (+ optional BOM) W3C WebVTT § 4.1 Reject before consuming compute
Frame duration (29.97) 33.3667 ms SMPTE ST 12-1 Use Fraction(30000, 1001) for mapping

Verification & test pattern

Lock each threshold behind a pytest fixture so a config change that loosens a gate fails CI rather than silently shipping a non-compliant asset. Test both sides of every boundary — exactly at the limit must pass, one unit beyond must fail.

import pytest
from validator import Cue, validate_cue, _ts_to_ms, MAX_OVERLAP_MS

def test_timestamp_normalization():
    assert _ts_to_ms("01:02:03.456") == 3_723_456   # W3C §4.3 HH:MM:SS.mmm
    assert _ts_to_ms("02:03.4") == 123_400           # MM:SS.m tolerated + padded

def test_line_width_boundary():
    ok  = Cue(0, 0, 2000, "X" * 32)                  # ANSI/CTA-608-E grid width
    bad = Cue(1, 0, 2000, "X" * 33)
    assert validate_cue(ok, None) == []
    assert any("line width" in m for m in validate_cue(bad, None))

def test_overlap_just_over_tolerance_fails():
    prev = Cue(0, 0, 2000, "hello")
    # next cue starts 51 ms before prev ends -> exceeds 50 ms flush window
    nxt = Cue(1, 2000 - (MAX_OVERLAP_MS + 1), 4000, "world")
    assert any("overlap" in m for m in validate_cue(nxt, prev))

def test_reading_speed_flags_dense_cue():
    cue = Cue(0, 0, 1000, "supercalifragilistic")    # 20 chars in 1.0 s = 20 CPS
    assert any("CPS" in m for m in validate_cue(cue, None))

A useful fixture is a deliberately broken .vtt containing a BOM, a NOTE block, a 33-character line, and a 60 ms overlap; a correct pipeline must strip the first two and flag the last two with the exact cue indices.

Troubleshooting / failure modes

webvtt-py raises MalformedFileError on a file that plays fine : Root cause: a BOM or non-UTF-8 encoding (cp1252 from a Windows NLE) precedes the WEBVTT magic string. Fix: run Step 1’s sanitize_header with charset_normalizer before parsing; never hand the raw path to webvtt.read for vendor exports.

Reading-speed gate passes but captions feel rushed on screen : Root cause: CPS computed on text that still contains inline <c.classname> / <v Speaker> markup, inflating the duration-normalized character count’s denominator or counting tag characters. Fix: strip cue markup to plain text before measuring; only displayed glyphs count toward the 17 CPS ceiling.

Overlap violations fire on every cue in a roll-up-style file : Root cause: the source intentionally overlaps cues for a paint-on effect, or end times were authored as the next cue’s start. Fix: distinguish authored overlap from drift by checking whether the overlap is constant; if it is a deliberate style, relax MAX_OVERLAP_MS for that asset class via config rather than in code.

Timestamps drift by whole seconds after NLE export : Root cause: a frame-rate conversion (25 fps timeline exported against a 23.976 fps master) applied during export, producing cumulative not constant error. Fix: this is a timeline problem, not a parse problem — hand the normalized cues to mapping WebVTT cues to broadcast timelines for offset and frame-rate correction.

Line-count gate misses a long single line that wraps to three rows on the decoder : Root cause: the cue has one logical line over 32 characters that the renderer wraps; splitlines() sees one line, the screen shows three. Fix: validate effective rows by simulating a 32-column word wrap, not just counting explicit \n breaks.

Duplicate or empty cue identifiers crash a downstream serializer : Root cause: WebVTT cue identifiers are optional and need not be unique; code that keys a dict on cap.identifier collides or drops cues. Fix: key on positional index, treat the identifier as opaque metadata only.

Operational notes

At batch scale this stage is CPU-light and I/O-bound: the cost is reading and decoding files, not the arithmetic. Stream cues from webvtt.read_buffer and validate incrementally rather than materializing every Caption object — a feature-length program is tens of thousands of cues, and only the previous cue is needed for the overlap check, so memory stays flat regardless of runtime. Keep per-worker resident size bounded by holding one cue plus its predecessor, never the whole list, when you only need the verdict.

Size the worker pool to roughly the number of physical cores; because the work is short and file-bound, a process pool around the decode call scales better than threads fighting the GIL. For very large libraries, route this stage through the shared async batch caption processing runner so WebVTT, SCC, and SRT assets share one quarantine queue and backpressure policy. Keep every validator stateless and idempotent — the same asset must always yield the same verdict — and write each verdict, pass or fail, to an append-only store keyed by asset_id and content hash so the compliance trail survives the asset.

Part of: SRT, SCC & WebVTT Parsing Workflows.