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:, or align: settings is not reproduced by the single default region. Detect non-default settings on cue and synthesise an extra region/style per distinct placement, then reference it from that cue’s p; 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> to tts: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.mmm for sub-hour cues, so cue.start as a raw string may lack an hours field. Always route through start_in_seconds and media_time so 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 WEBVTT header can make some readers mis-sniff the file. Strip the BOM (open with encoding="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; filter lines for 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 NOTE comment blocks carry no timing and must not become p elements. webvtt.read already skips comments, but if you preserve identifiers, map them to xml:id on the p rather 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 p elements 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.

Part of: Caption Muxing, Packaging & Delivery — the reference for turning validated caption cues into deliverable broadcast and OTT containers.