WebVTT Segmentation for HLS

The exact failure this page solves is a single WebVTT file dropped into an HLS subtitle rendition without segmentation or time-mapping, so the player either loads all cues against local zero — ignoring the media’s presentation offset and drifting the full track — or, once naively sliced, plays each segment against the wrong point on the 90 kHz MPEG-2 clock. HLS requires the subtitle rendition to be cut into the same N-second segments as the video ladder (typically 6 s), and every .vtt segment must carry an X-TIMESTAMP-MAP=MPEGTS:<pts>,LOCAL:00:00:00.000 header that pins that segment’s local zero to its true position on the 90 000 Hz timeline, where 10 seconds equals exactly 900000 ticks. This is the segmentation step the parent HLS & DASH caption delivery workflow depends on, isolated here with the per-segment offset math, boundary-spanning handling, and 33-bit PTS rollover it has to get right.

import math
import webvtt

CLOCK_HZ = 90_000            # MPEG-2 90 kHz system clock; 10 s == 900000 ticks
PTS_MODULO = 1 << 33         # 33-bit PTS field wraps at 2**33 (~26.5 h)
PTS_OFFSET = 900_000         # stream start offset in ticks (Apple HLS default: 10 s)
SEG_SECONDS = 6.0            # HLS target segment duration; align to the video ladder

def ts_to_seconds(ts: str) -> float:
    hh, mm, rest = ts.split(":")            # W3C WebVTT grammar: HH:MM:SS.mmm
    ss, ms = rest.split(".")
    return int(hh) * 3600 + int(mm) * 60 + int(ss) + int(ms) / 1000

def seconds_to_ts(sec: float) -> str:
    sec = max(0.0, sec)                      # WebVTT has no negative timestamps
    ms = int(round(sec * 1000))
    hh, ms = divmod(ms, 3_600_000)
    mm, ms = divmod(ms, 60_000)
    ss, ms = divmod(ms, 1000)
    return f"{hh:02d}:{mm:02d}:{ss:02d}.{ms:03d}"

def segment_pts(seg_start: float, offset: int = PTS_OFFSET) -> int:
    # Per-segment MPEGTS anchor = stream offset + segment start, wrapped to 33 bits.
    return (offset + int(round(seg_start * CLOCK_HZ))) % PTS_MODULO

def segment_for_hls(src: str, out_dir: str, seg_seconds: float = SEG_SECONDS):
    cues = list(webvtt.read(src))
    if not cues:
        return []
    total = max(ts_to_seconds(c.end) for c in cues)
    n_segments = int(total // seg_seconds) + 1
    playlist = ["#EXTM3U", "#EXT-X-VERSION:6",
                f"#EXT-X-TARGETDURATION:{math.ceil(seg_seconds)}",
                "#EXT-X-MEDIA-SEQUENCE:0", "#EXT-X-PLAYLIST-TYPE:VOD"]
    for idx in range(n_segments):
        seg_start, seg_end = idx * seg_seconds, (idx + 1) * seg_seconds
        # A cue is a member if its interval overlaps the segment at all, so a cue
        # crossing a boundary is duplicated into every segment it touches.
        members = [c for c in cues
                   if ts_to_seconds(c.start) < seg_end
                   and ts_to_seconds(c.end) > seg_start]
        header = ["WEBVTT",
                  f"X-TIMESTAMP-MAP=MPEGTS:{segment_pts(seg_start)},LOCAL:00:00:00.000", ""]
        body = []
        for c in members:
            # Rebase to segment-local time and clamp into [0, seg_seconds]; the
            # per-segment MPEGTS offset compensates so playback stays continuous.
            local_start = seconds_to_ts(ts_to_seconds(c.start) - seg_start)
            local_end = seconds_to_ts(min(ts_to_seconds(c.end) - seg_start, seg_seconds))
            body.append(f"{local_start} --> {local_end}\n{c.text}\n")
        name = f"seg_{idx:05d}.vtt"
        with open(f"{out_dir}/{name}", "w", encoding="utf-8") as fh:
            fh.write("\n".join(header) + "\n" + "\n".join(body))
        playlist.append(f"#EXTINF:{seg_seconds:.3f},")   # empty segments still get an EXTINF line
        playlist.append(name)
    playlist.append("#EXT-X-ENDLIST")
    with open(f"{out_dir}/subs.m3u8", "w", encoding="utf-8") as fh:
        fh.write("\n".join(playlist) + "\n")
    return playlist

Code walkthrough

webvtt.read returns a list of Caption objects whose .start, .end, and .text attributes are already normalized to the W3C grammar, so the segmenter never touches raw bytes — it works on a clean cue model. Materializing the list once with list(...) matters because the membership filter reads it repeatedly, one pass per segment; re-parsing the file each time would turn a linear job into a quadratic one on long programs.

ts_to_seconds and seconds_to_ts are a matched pair that convert between the W3C WebVTT HH:MM:SS.mmm grammar and a float second count, doing all rounding in integer milliseconds so a cue never gains or loses a millisecond on the round trip. Floating-point seconds are used only for the interval arithmetic; the final timestamp is always re-quantized to whole milliseconds before it is written, which keeps the segment output stable enough for a golden-file comparison. seconds_to_ts clamps negative inputs to 00:00:00.000 because WebVTT has no representation for a negative timestamp — that clamp is what makes boundary-spanning cues legal in the segment where they begin before the segment’s own zero.

segment_pts is the heart of the time-map. For a segment starting at seg_start seconds, its local zero corresponds to media time seg_start, which sits at tick PTS_OFFSET + seg_start × 90000 on the 90 kHz clock. That integer is the MPEGTS value in the segment’s X-TIMESTAMP-MAP. The % PTS_MODULO folds the value back into the 33-bit range an MPEG-2 PTS field can actually hold, so a program longer than ~26.5 hours does not emit an out-of-range anchor. Because the cue times inside each segment are rebased to segment-local time, the player computes each cue’s true media position as local_time + MPEGTS/90000, and the per-segment offset makes that sum land exactly where the absolute cue was authored — even across a boundary, where the two halves reconstruct the full cue continuously.

segment_for_hls walks fixed seg_seconds windows from zero to the last cue end. The membership test start < seg_end and end > seg_start is a standard interval-overlap check: any cue that touches the window is included, so a cue crossing a boundary appears in both adjacent segments rather than vanishing at the seam. Each member is rebased (cue_time − seg_start) and its end clamped to the segment length, then written under a fresh WEBVTT + X-TIMESTAMP-MAP header. The function simultaneously builds the media playlist — #EXTINF plus filename per segment — and terminates it with #EXT-X-ENDLIST for VOD. #EXT-X-TARGETDURATION is the ceiling of the segment length, satisfying the RFC 8216 rule that it must not be less than any #EXTINF.

One design choice deserves a note: this segmenter rebases every cue to segment-local time and computes a distinct MPEGTS anchor per segment, rather than keeping absolute cue times under a single constant MPEGTS:900000 map. Both are valid HLS. Rebasing makes each .vtt segment fully self-describing — its cues read 00:00:00.000-relative and its anchor states exactly where that zero sits on the media clock — which is easier to reason about when segments are cached, re-ordered, or served from a rolling live window where the absolute program time is large. It is also the form that survives 33-bit PTS rollover cleanly, because only the anchor integer wraps while the local cue times stay small. The trade is one extra subtraction per cue per segment, which is negligible against the file I/O.

A worked offset example

Take a cue authored at 00:00:05.000 --> 00:00:09.000 with 6-second segments and the default 900000 (10 s) stream offset. Its interval [5, 9) overlaps two windows, so it is written into both.

In segment 0 ([0, 6)) the anchor is segment_pts(0) = 900000 + 0 = 900000, and the cue is rebased to 00:00:05.000 --> 00:00:06.000 (its end clamped to the segment length). A player reconstructs the media time as local + MPEGTS/90000 = 5..6 + 10 = 15..16 s. In segment 1 ([6, 12)) the anchor is segment_pts(6) = 900000 + 6 × 90000 = 1440000, which is 16 s on the clock, and the cue is rebased to 00:00:00.000 --> 00:00:03.000 (its start clamped up from −1 s to zero). The player reconstructs 0..3 + 16 = 16..19 s. The two halves — 15..16 s from segment 0 and 16..19 s from segment 1 — join with no gap and no overlap, reproducing the original [5, 9) cue against the media’s true [15, 19) position. The per-segment MPEGTS offset is exactly what absorbs the clamp, which is why rebasing stays frame-accurate across a boundary.

A boundary-spanning cue duplicated into two segments with per-segment MPEGTS anchors A source cue spanning 5 to 9 seconds crosses the 6-second segment boundary. It is written into segment 0 with anchor MPEGTS 900000 and local time 5 to 6 seconds, and into segment 1 with anchor MPEGTS 1440000 and local time 0 to 3 seconds. Each segment's per-segment MPEGTS offset plus its local cue time reconstructs the same continuous media interval, 15 to 19 seconds. One cue [5 s, 9 s) duplicated across the 6 s boundary source 6 s boundary cue 5 s → 9 s seg_00000.vtt · [0 s, 6 s) MPEGTS:900000 local 00:00:05.000 → 06.000 → media 15 s → 16 s seg_00001.vtt · [6 s, 12 s) MPEGTS:1440000 local 00:00:00.000 → 03.000 → media 16 s → 19 s 15 s → 16 s and 16 s → 19 s join with no gap: the original [15 s, 19 s) media interval.

Threshold reference table

Parameter Value Source / clause
MPEG-2 system clock 90 000 Hz ISO/IEC 13818-1 (MPEG-2 systems)
Ticks per 10 s 900000 90 kHz × 10 s
PTS field width / rollover 33 bits, wrap at 2³³ (8589934592) MPEG-2 systems PTS
Default stream start offset 900000 (10 s) Apple HLS authoring convention
Per-segment MPEGTS PTS_OFFSET + seg_start × 90000 HLS WebVTT time-map
Target segment duration 6 s Apple HLS authoring spec
#EXT-X-TARGETDURATION ceil(max #EXTINF) RFC 8216 §4.4.3.1
Boundary-spanning cue duplicate into every overlapped segment HLS segmented WebVTT

Edge cases & known gotchas

  • A cue spans a segment boundary. The interval-overlap membership test duplicates the cue into every segment it touches, and each copy is clamped to that segment’s [0, seg_seconds] window. Dropping the duplication makes the cue blink out at the seam when the player advances to the next segment.
  • PTS rollover at 2³³. A 33-bit PTS wraps roughly every 26.5 hours. For 24/7 linear origins, segment_pts applies % PTS_MODULO so the anchor stays in range; the player already expects the wrapped value because the media PTS wraps identically.
  • Non-zero stream start offset. If the packaged media does not begin at PTS 900000, set PTS_OFFSET to the media’s first video PTS. A mismatch shifts every caption by a constant, which reads as a fixed lead or lag against the picture.
  • Empty segments. A window with no overlapping cues still gets a header-only .vtt file and its own #EXTINF entry, because HLS requires the subtitle playlist to have the same segment count and timeline as the video playlist — skipping empty segments desynchronizes the renditions.
  • Fractional segment durations. A non-integer seg_seconds (for example 6.006 for 29.97 fps alignment) still needs an integer #EXT-X-TARGETDURATION; math.ceil rounds up so the declared target never falls below an actual #EXTINF. The #EXTINF value itself stays fractional to three decimals so cumulative segment starts do not slew away from the media boundaries over a long program.
  • Cue styling and positioning tags. WebVTT cue settings (line:, position:, align:) and inline markup ride along in c.text untouched, so a positioned caption keeps its placement in every segment it is duplicated into. Strip only what the broadcast target cannot render; do not silently drop settings during segmentation or a lower-third caption can jump to center mid-cue.
  • Off-by-one segment count. The int(total // seg_seconds) + 1 count guarantees a segment for the window that contains the final cue end even when it lands exactly on a boundary. Dropping the + 1 truncates the last segment and the closing cue disappears from playback.

Integration hook

This segmenter is the HLS-branch implementation of the delivery step described in HLS & DASH caption delivery: it consumes a QC-passed cue list and emits the .vtt segments plus the subtitle rendition playlist that the master playlist references with its #EXT-X-MEDIA:TYPE=SUBTITLES line. The per-segment X-TIMESTAMP-MAP it computes is the same 90 kHz anchor the parent uses to keep captions frame-aligned; where the parent keeps absolute cue times under a constant map, this page rebases per segment so the offset math is explicit and rollover-safe. The absolute-time reasoning behind the anchor is the broadcast-timeline mapping detailed in mapping WebVTT cues to broadcast timelines, and segmenting a large multi-language library in parallel follows the bounded-worker pattern in async batch caption processing.

Part of: Caption Muxing, Packaging & Delivery — the container, mux and delivery reference for broadcast and OTT caption tracks.