Converting WebVTT to IMSC1
The exact failure this page prevents is a WebVTT-to-IMSC1 conversion that quietly loses timing or line structure: a cue whose HH:MM:SS.mmm in-time is truncated to whole seconds, a two-line caption flattened into one, or an inline <c>/<b> tag that leaks into the rendered text as literal markup. WebVTT and IMSC1 both express cue timing in the media time base, so the conversion should be lossless to the millisecond — every WebVTT cue maps to exactly one TTML p with a begin/end that round-trips, and every authored line break becomes a <br/>. What breaks that guarantee is treating the WebVTT cue text as an opaque string instead of a structured object: line boundaries and inline spans need explicit handling, or they vanish. The document skeleton produced here is the one specified by the parent IMSC1 & TTML packaging for OTT reference, narrowed to a single-source conversion.
import re
import sys
import webvtt # webvtt-py >= 0.4.6
from lxml import etree
# --- TTML1 / IMSC1 namespace contract (W3C IMSC1 §5) ---
TT = "http://www.w3.org/ns/ttml"
TTP = "http://www.w3.org/ns/ttml#parameter"
TTS = "http://www.w3.org/ns/ttml#styling"
XML = "http://www.w3.org/XML/1998/namespace"
NSMAP = {None: TT, "ttp": TTP, "tts": TTS}
IMSC1_TEXT = "http://www.w3.org/ns/ttml/profile/imsc1/text"
TAGS = re.compile(r"<[^>]+>") # WebVTT inline cues: <c>, <b>, <i>, <v Bob>, <00:00:01.000>
def q(ns, name):
return etree.QName(ns, name)
def media_time(seconds: float) -> str:
# TTML1 §10.3.1 media-time clock expression; hours field keeps timestamps > 1h exact
ms = round(seconds * 1000)
h, ms = divmod(ms, 3_600_000)
m, ms = divmod(ms, 60_000)
s, ms = divmod(ms, 1000)
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
def convert(vtt_path: str, lang: str = "en") -> bytes:
tt = etree.Element(q(TT, "tt"), nsmap=NSMAP)
tt.set(q(TTP, "timeBase"), "media") # WebVTT is media-time; IMSC1 requires 'media'
tt.set(q(TTP, "cellResolution"), "32 15") # TTML1 §7.2.1 default cell grid
tt.set(q(TTP, "profile"), IMSC1_TEXT) # conform to IMSC1 Text profile
tt.set(q(XML, "lang"), lang)
head = etree.SubElement(tt, q(TT, "head"))
styling = etree.SubElement(head, q(TT, "styling"))
style = etree.SubElement(styling, q(TT, "style"))
style.set(q(XML, "id"), "base")
style.set(q(TTS, "color"), "white")
style.set(q(TTS, "backgroundColor"), "black")
style.set(q(TTS, "fontFamily"), "proportionalSansSerif")
style.set(q(TTS, "fontSize"), "80%")
style.set(q(TTS, "textAlign"), "center")
layout = etree.SubElement(head, q(TT, "layout"))
region = etree.SubElement(layout, q(TT, "region"))
region.set(q(XML, "id"), "full")
region.set(q(TTS, "origin"), "10% 80%") # % of root; WebVTT default line is bottom
region.set(q(TTS, "extent"), "80% 15%")
region.set(q(TTS, "displayAlign"), "after")
body = etree.SubElement(tt, q(TT, "body"))
div = etree.SubElement(body, q(TT, "div"))
for cue in webvtt.read(vtt_path):
p = etree.SubElement(div, q(TT, "p"))
p.set("region", "full")
p.set("style", "base")
p.set("begin", media_time(cue.start_in_seconds)) # lossless: parsed to ms then reformatted
p.set("end", media_time(cue.end_in_seconds))
# Preserve each authored line as <br/>; strip inline markup for lossless plain text
lines = [TAGS.sub("", ln).strip() for ln in cue.lines]
for i, line in enumerate(lines):
if i == 0:
p.text = line
else:
br = etree.SubElement(p, q(TT, "br"))
br.tail = line
return etree.tostring(tt, xml_declaration=True, encoding="UTF-8", pretty_print=True)
if __name__ == "__main__":
sys.stdout.buffer.write(convert(sys.argv[1]))
Code walkthrough
media_time is the reason the conversion is lossless. Both WebVTT and IMSC1 measure time from the start of the presentation, but a naive conversion that formats through datetime or truncates to whole seconds discards the millisecond field that WebVTT carries. Parsing to an integer millisecond count and reformatting to HH:MM:SS.mmm preserves every cue boundary exactly and — critically — always emits the hours field, so a cue at 01:23:45.678 does not collapse to 23:45.678 the way a minute-first WebVTT timestamp might. webvtt-py exposes cue.start_in_seconds and cue.end_in_seconds as floats already normalised from the source, so rounding to the nearest millisecond is the only arithmetic needed.
The namespace contract on the tt root is the minimum an IMSC1 Text-profile document must declare: the default TTML namespace plus ttp for parameters and tts for styling. ttp:timeBase="media" is not optional — it is the single most common conversion defect, because tools that originate from broadcast TTML default to the smpte base, which IMSC1 rejects. Setting cellResolution="32 15" and the Text Profile designator completes the header the parent IMSC1 & TTML packaging for OTT reference enumerates in its threshold table.
The head builds a single reusable style and a single bottom-anchored region. WebVTT’s default cue position is the lower-centre safe area, so a region at origin="10% 80%" with extent="80% 15%" and displayAlign="after" reproduces the WebVTT default without per-cue positioning. Cues that carry explicit WebVTT settings need more than this one region — see the edge cases below.
The cue loop is where structure is preserved. cue.lines gives the caption as a list of already-split lines rather than one newline-joined string, so line boundaries are known, not guessed. Each line is stripped of inline WebVTT tags with the TAGS regex — <c.classname>, <b>, <i>, voice spans like <v Bob>, and inline <00:00:01.000> timestamps — leaving lossless plain text. The first line becomes the p element’s text; each subsequent line is attached as the tail of a <br/> element, which is exactly how TTML encodes an authored line break inside a paragraph. Encoding line breaks as elements rather than literal \n characters matters because a TTML processor collapses whitespace inside p by default, so a newline embedded in the text node would be rendered as a single space and the two-line caption would silently become one.
A single region and a single style are deliberate. WebVTT that carries no per-cue placement settings always renders in the same lower-centre safe area, so reusing one region and one style keeps the output compact and lets the player apply user restyling uniformly — the payoff of the Text profile over burned-in images. The conversion is one-to-one at the cue level: N WebVTT cues produce exactly N p elements in document order, with no merging or splitting, which is what makes the timing round-trip auditable. To prove losslessness, reparse the emitted TTML, read each p’s begin/end back to milliseconds, and assert they equal the source start_in_seconds/end_in_seconds rounded to the millisecond; any mismatch localises to a single cue index rather than hiding in an aggregate.
Threshold reference table
| Parameter | Value | Source / clause |
|---|---|---|
| Time base | media |
W3C IMSC1 §6.1 |
| Timestamp precision | milliseconds (.mmm) |
W3C WebVTT timestamp grammar |
| Profile designator | http://www.w3.org/ns/ttml/profile/imsc1/text |
W3C IMSC1 §5.2 |
| Cell resolution | 32 15 |
TTML1 §7.2.1 |
| Region geometry units | percentage of root | W3C IMSC1 §5.4 |
| Line break encoding | <br/> element |
TTML1 §8.2 |
| Document encoding | UTF-8 | W3C IMSC1 §5.1 |
Edge cases & known gotchas
- Cue settings and position → region mapping: a WebVTT cue with
position:,line:, oralign:settings is not reproduced by the single default region. Detect non-default settings oncueand synthesise an extraregion/styleper distinct placement, then reference it from that cue’sp; the broadcast-timeline geometry behind these mappings is worked through in mapping WebVTT cues to broadcast timelines. <c>/<b>/<i>inline tags: the converter strips them for lossless plain text. If you must preserve emphasis, map<b>to a<span tts:fontWeight="bold">and<i>totts:fontStyle="italic"instead of discarding them — but only those styling properties that the IMSC1 subset permits, per TTML & IMSC1 format compliance.- Timestamps beyond one hour: WebVTT allows
MM:SS.mmmfor sub-hour cues, socue.startas a raw string may lack an hours field. Always route throughstart_in_secondsandmedia_timeso the emitted TTML time is fully qualified and monotonic across the hour boundary. - UTF-8 BOM on the source: a byte-order mark on the WebVTT
WEBVTTheader can make some readers mis-sniff the file. Strip the BOM (open withencoding="utf-8-sig") before parsing so the first cue is not silently dropped. - Empty or whitespace-only lines: a cue with a trailing blank line produces an empty
<br/>tail; filterlinesfor non-empty content so you do not emit hollow line breaks that add vertical space at the player. - Cue identifiers and NOTE blocks: WebVTT optional cue identifiers and
NOTEcomment blocks carry no timing and must not becomepelements.webvtt.readalready skips comments, but if you preserve identifiers, map them toxml:idon theprather than to visible text, or they leak into the caption. - Overlapping cues: WebVTT permits two cues whose times overlap (for roll-up or paint-on effects); IMSC1 allows the same, but stacked overlapping
pelements in one region can collide visually. Where the source relies on overlap for scrolling, verify the rendered result in a real IMSC1 player rather than assuming a one-region layout reproduces it.
Integration hook
This converter is the WebVTT entry point into the packaging step the parent IMSC1 & TTML packaging for OTT reference defines: it produces the same conformant Text-profile document that a segmenter later fragments for DASH and CMAF delivery, differing only in that it derives its cue model from an existing WebVTT sidecar rather than an upstream broadcast source. Feed its output straight into the parent’s verification routine so the parameter contract and reference integrity are asserted before the sidecar reaches a packager.
Related
- IMSC1 & TTML packaging for OTT — the full document contract and profile-validation this conversion targets.
- Mapping WebVTT cues to broadcast timelines — cue position and timing semantics behind region mapping.
- TTML & IMSC1 format compliance — which inline styling features survive inside the IMSC1 subset.
Part of: Caption Muxing, Packaging & Delivery — the reference for turning validated caption cues into deliverable broadcast and OTT containers.