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.
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_ptsapplies% PTS_MODULOso 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_OFFSETto 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
.vttfile and its own#EXTINFentry, 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.ceilrounds up so the declared target never falls below an actual#EXTINF. The#EXTINFvalue 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 inc.textuntouched, 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) + 1count guarantees a segment for the window that contains the final cue end even when it lands exactly on a boundary. Dropping the+ 1truncates 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.
Related
- HLS & DASH caption delivery — the parent workflow: manifests, DASH text AdaptationSets and the constant-map alternative.
- Mapping WebVTT cues to broadcast timelines — the absolute-time anchoring the X-TIMESTAMP-MAP encodes.
- Async batch caption processing — bounded-worker I/O for segmenting many language tracks at once.
Part of: Caption Muxing, Packaging & Delivery — the container, mux and delivery reference for broadcast and OTT caption tracks.