IMSC1 & TTML Packaging for OTT
An OTT platform rejects a timed-text sidecar the moment it violates a single IMSC1 constraint, and the failure is almost never in the caption content — it is in the document’s parameter contract. A file with the right cues but the wrong ttp:timeBase, an absent profile designator, or percentage geometry where cell units are expected will parse as valid XML, decode as valid TTML, and still be refused by a DASH packager or an Apple HLS ingest as non-conformant IMSC1. The engineering problem this page solves is emitting a sidecar that is legal against the W3C IMSC1 recommendation on the first attempt: the exact namespaces, the media time base, the 32 15 cell grid, region and style references that resolve, and a profile designator the receiver recognizes. This is the timed-text arm of the broader Caption Muxing, Packaging & Delivery track, and it is where a broadcast cue model becomes a streaming-ready artifact.
IMSC1 (Internet Media Subtitles and Captions 1.0) is not a new file format — it is a profile of TTML1, a constrained subset with two flavours: a Text Profile that carries styled text regions and an Image Profile that carries PNG subtitle images. OTT delivery over DASH, CMAF, and fragmented-MP4 HLS standardises on the Text Profile because it is language-searchable, restylable at the player, and a fraction of the byte weight of burned-in images. Getting it right means treating the TTML document as a strict tree with a fixed parameter header, not as free-form markup you decorate until it looks correct.
Problem Framing: Why a Valid TTML File Is Still an Invalid Sidecar
TTML1 is a large, permissive language; IMSC1 is the small, opinionated subset that streaming ecosystems actually accept. The gap between “well-formed TTML” and “conformant IMSC1” is where OTT ingest failures live, and it is quantifiable. IMSC1 fixes the time base to media — offset time measured from the start of the presentation — and forbids the smpte and clock time bases that broadcast TTML profiles allow; a document with ttp:timeBase="smpte" is rejected outright. It requires a profile signal: the Text Profile designator http://www.w3.org/ns/ttml/profile/imsc1/text (or the Image Profile) declared so the receiver knows which constraint set applies. And it constrains geometry and units — region extents in percentages of the root container, font sizes in cell (c) or percentage units against a ttp:cellResolution grid that defaults to 32 15.
Each of these is a hard pass/fail boundary, not a warning. A DASH packager following DASH-IF timed-text interoperability guidance treats a missing profile designator as an unrecognised subtitle track and drops it from the MPD. Apple’s HLS authoring specification requires IMSC1 in CMAF for fragmented-MP4 subtitle delivery, and its media validation tools flag the same conformance points. The practical consequence is that packaging is a validation problem before it is a content problem: you are not asking “do the captions read well?” — that is the job of the upstream QC layer — you are asking “does this document satisfy every structural invariant the receiver checks?” This page builds a document that does, from the parameter header down.
Pipeline Stage & Prerequisites
IMSC1 authoring sits after caption content is final and normalized but before segmentation and muxing. The input is a cue model — cues with media-relative in/out times and line structure, exactly the normalized shape produced by the SRT, SCC & WebVTT parsing workflows and validated by WebVTT cue extraction and validation. The output is a single self-contained TTML document that a segmenter can later split into fragments for HLS and DASH caption delivery. Unlike the in-band route that embeds CEA-608 and CEA-708 in transport streams, the IMSC1 sidecar rides alongside the media as a separate rendition, referenced from the manifest rather than carried in the video elementary stream.
Required tooling:
| Tool / Library | Version | Role in packaging |
|---|---|---|
| Python | ≥ 3.9 | Runtime; f-strings and := used below |
lxml |
≥ 4.9 | Namespace-aware TTML tree construction and serialization |
webvtt-py |
≥ 0.4.6 | Reads source WebVTT cues for conversion pipelines |
imscHTML / ttconv (reference) |
current | Third-party IMSC1 validation and rendering cross-check |
xmllint (libxml2) |
≥ 2.9 | Well-formedness and namespace sanity in CI |
Use lxml rather than the stdlib xml.etree.ElementTree for one decisive reason: IMSC1 depends on precise namespace prefixes and xml:id / xml:lang attributes, and lxml gives you etree.QName control over both the element namespace and every attribute namespace. ElementTree mangles prefixes and cannot cleanly emit the ittp/itts extension attributes IMSC1 defines, so the moment you need a real profile designator or an EBU-TT-D style hook, it stops being sufficient.
The IMSC1 Namespace Contract
Every IMSC1 document declares a fixed set of namespaces on the root tt element. Getting one URI wrong silently strips the attributes bound to it — a mistyped tts namespace means every tts:extent is ignored and regions collapse to nothing. The core set is TTML1’s, extended by IMSC1’s own profile namespaces and, optionally, EBU-TT-D styling:
| Prefix | Namespace URI | Carries |
|---|---|---|
| (default) | http://www.w3.org/ns/ttml |
tt, head, body, div, p, span, region, style |
ttp |
http://www.w3.org/ns/ttml#parameter |
timeBase, frameRate, frameRateMultiplier, cellResolution, profile |
tts |
http://www.w3.org/ns/ttml#styling |
extent, origin, color, fontSize, textAlign, displayAlign |
ttm |
http://www.w3.org/ns/ttml#metadata |
title, desc, agent metadata |
ittp |
http://www.w3.org/ns/ttml/profile/imsc1#parameter |
aspectRatio, progressivelyDecodable |
itts |
http://www.w3.org/ns/ttml/profile/imsc1#styling |
fillLineGap, forcedDisplay, multiRowAlign |
ebutts |
urn:ebu:tt:style |
linePadding, multiRowAlign (EBU-TT-D interop) |
The parameter attributes on tt are the header that turns generic TTML into an IMSC1 profile instance. ttp:timeBase="media" fixes the clock; ttp:frameRate="30" with ttp:frameRateMultiplier="1000 1001" describes 29.97 fps content so frame-denominated times resolve correctly; ttp:cellResolution="32 15" establishes the 32-column by 15-row logical grid that broadcast captions have used since CEA-608, and against which cell-unit font sizes are measured; and ttp:profile (or ttp:contentProfiles in IMSC 1.1) carries the designator that names the profile.
Step-by-Step Implementation
The following builds a complete, conformant IMSC1 Text-profile document from a list of cues using lxml.etree. Each step maps to a subtree of the diagram above.
Step 1 — Declare the root and its parameter contract
Create the tt root with the full namespace map, then set the IMSC1 parameter attributes. Because every parameter attribute lives in the ttp namespace, use etree.QName for each so lxml binds the prefix correctly.
from lxml import etree
# W3C TTML1 §5 namespaces + IMSC1 profile namespaces
TT = "http://www.w3.org/ns/ttml"
TTP = "http://www.w3.org/ns/ttml#parameter"
TTS = "http://www.w3.org/ns/ttml#styling"
TTM = "http://www.w3.org/ns/ttml#metadata"
ITTP = "http://www.w3.org/ns/ttml/profile/imsc1#parameter"
ITTS = "http://www.w3.org/ns/ttml/profile/imsc1#styling"
XML = "http://www.w3.org/XML/1998/namespace"
NSMAP = {None: TT, "ttp": TTP, "tts": TTS, "ttm": TTM, "ittp": ITTP, "itts": ITTS}
IMSC1_TEXT = "http://www.w3.org/ns/ttml/profile/imsc1/text" # Text Profile designator
def q(ns, name):
return etree.QName(ns, name)
def new_document(lang: str = "en") -> etree._Element:
tt = etree.Element(q(TT, "tt"), nsmap=NSMAP)
tt.set(q(TTP, "timeBase"), "media") # IMSC1 §6.1 — only 'media' is legal for OTT
tt.set(q(TTP, "frameRate"), "30") # nominal frame rate
tt.set(q(TTP, "frameRateMultiplier"), "1000 1001") # 30000/1001 = 29.97 NTSC
tt.set(q(TTP, "cellResolution"), "32 15") # TTML1 §7.2.1 root cell grid (CEA-608 heritage)
tt.set(q(TTP, "profile"), IMSC1_TEXT) # declare conformance to the Text profile
tt.set(q(XML, "lang"), lang) # required root language
return tt
Step 2 — Define styling and layout
IMSC1 separates how text looks (styling → style) from where it goes (layout → region). A region carries tts:origin and tts:extent as percentages of the root container; content elements reference a style and a region by id. Anchor the caption block to the bottom safe area with tts:displayAlign="after".
def add_head(tt: etree._Element) -> None:
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"), "s_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%") # percentage of the 32x15 cell grid
style.set(q(TTS, "textAlign"), "center")
style.set(q(TTS, "lineHeight"), "125%")
layout = etree.SubElement(head, q(TT, "layout"))
region = etree.SubElement(layout, q(TT, "region"))
region.set(q(XML, "id"), "r_bottom")
region.set(q(TTS, "origin"), "10% 80%") # % of root container (IMSC1 forbids px roots)
region.set(q(TTS, "extent"), "80% 15%") # width x height as % of root
region.set(q(TTS, "displayAlign"), "after") # push lines to the region bottom
Step 3 — Emit the body cue tree
The body holds a single div of p elements. Each p sets begin/end as media-time clock expressions (HH:MM:SS.mmm) and references the region and style by id — note that begin, end, region, and style are unprefixed attributes in no namespace, a common source of silent failure when authors wrongly namespace them.
def media_time(seconds: float) -> str:
# TTML1 §10.3.1 clock-time in the media time base; hours never wrap past 24
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 add_body(tt: etree._Element, cues: list[dict]) -> None:
body = etree.SubElement(tt, q(TT, "body"))
div = etree.SubElement(body, q(TT, "div"))
for cue in cues:
p = etree.SubElement(div, q(TT, "p"))
p.set("region", "r_bottom") # unprefixed: TTML content attribute
p.set("style", "s_base")
p.set("begin", media_time(cue["start"])) # media time base offset
p.set("end", media_time(cue["end"]))
# Preserve multi-line structure with <br/>; first line is element text
for i, line in enumerate(cue["lines"]):
if i == 0:
p.text = line
else:
br = etree.SubElement(p, q(TT, "br"))
br.tail = line
Step 4 — Serialize the sidecar
Serialize with an XML declaration and UTF-8 so the receiver’s parser has no ambiguity about encoding. pretty_print is safe for sidecars because whitespace between block elements is not rendered.
def build_imsc1(cues: list[dict], lang: str = "en") -> bytes:
tt = new_document(lang)
add_head(tt)
add_body(tt, cues)
return etree.tostring(tt, xml_declaration=True, encoding="UTF-8", pretty_print=True)
if __name__ == "__main__":
demo = [
{"start": 1.0, "end": 3.5, "lines": ["First caption line", "wraps to two rows"]},
{"start": 4.0, "end": 6.2, "lines": ["Second caption"]},
]
import sys
sys.stdout.buffer.write(build_imsc1(demo))
Running this emits a tt document whose header carries the Text Profile designator, whose head defines one style and one region, and whose body holds p elements with media-time begin/end — a sidecar a DASH or CMAF packager will accept. The dedicated conversion path from an existing WebVTT sidecar reuses this exact document skeleton and is covered in converting WebVTT to IMSC1.
Threshold Reference Table
Every fixed value the profile check asserts, with its source. Tune your authoring constants against this table, not against a receiver’s error messages.
| Parameter | Required value | Source / clause | Notes |
|---|---|---|---|
| Text Profile designator | http://www.w3.org/ns/ttml/profile/imsc1/text |
W3C IMSC1 §5.2 | On ttp:profile (1.0) or ttp:contentProfiles (1.1) |
| Image Profile designator | http://www.w3.org/ns/ttml/profile/imsc1/image |
W3C IMSC1 §5.2 | PNG subtitle images, not text |
| Time base | media |
W3C IMSC1 §6.1 | smpte/clock bases are rejected |
| Cell resolution | 32 15 |
TTML1 §7.2.1 | Default grid; CEA-608 column heritage |
| Frame rate (NTSC) | 30 × 1000 1001 |
SMPTE ST 12-1 | Yields 29.97; use 25/1 for PAL |
| Region geometry units | percentage of root | W3C IMSC1 §5.4 | Pixel root extents are non-conformant |
| Font size units | c (cell) or % |
W3C IMSC1 §5.4 | Measured against cellResolution |
| Root language | xml:lang present |
TTML1 §6.1.1 | Required on tt |
| Document encoding | UTF-8 | W3C IMSC1 §5.1 | Declare in the XML prolog |
Which of the many optional TTML styling features are actually legal inside these constraints — and how a validator enforces the subset — is the subject of the companion compliance reference, TTML & IMSC1 format compliance. Treat that page as the feature allow-list and this page as the construction recipe.
Verification & Test Pattern
A sidecar is only shippable once you have asserted its invariants programmatically. Reparse the serialized bytes with lxml, resolve namespaces, and assert the parameter contract plus referential integrity — every region/style reference on a p must resolve to a declared xml:id.
from lxml import etree
def verify_imsc1(xml_bytes: bytes) -> None:
ns = {"tt": TT, "ttp": TTP}
root = etree.fromstring(xml_bytes)
# Parameter contract
assert root.get(q(TTP, "timeBase")) == "media" # IMSC1 §6.1
assert root.get(q(TTP, "cellResolution")) == "32 15" # TTML1 §7.2.1
assert root.get(q(TTP, "profile")) == IMSC1_TEXT # profile signalled
assert root.get(q(XML, "lang")), "root xml:lang is required"
# Referential integrity: every region/style id referenced must exist
ids = {e.get(q(XML, "id")) for e in root.iter() if e.get(q(XML, "id"))}
for p in root.iterfind(".//tt:body//tt:p", ns):
assert p.get("region") in ids, f"dangling region {p.get('region')}"
assert p.get("style") in ids, f"dangling style {p.get('style')}"
assert p.get("begin") and p.get("end"), "cue missing begin/end"
if __name__ == "__main__":
verify_imsc1(build_imsc1([
{"start": 0.5, "end": 2.0, "lines": ["ok"]},
]))
print("IMSC1 invariants hold")
Run this in the same CI gate that blocks non-compliant artifacts before mux; the exit-code discipline is the one established in CI/CD gating for caption builds. Cross-check rendering with an independent IMSC1 renderer such as ttconv or imscJS so a document that passes your assertions but mis-renders on a real player is caught before delivery.
Troubleshooting / Failure Modes
ttp:timeBase="smpte" carried over from a broadcast profile
: Root cause: the source TTML was an SMPTE-TT or EBU-TT authoring document that used the SMPTE time base with embedded timecode. IMSC1 permits only media. Fix: convert every begin/end to media-time offsets from presentation start and set ttp:timeBase="media" on the root; do not leave both attributes present.
Missing or unrecognised profile designator
: Root cause: no ttp:profile attribute, or a stale TTML2 / custom designator URI. Fix: set ttp:profile to the exact Text Profile string http://www.w3.org/ns/ttml/profile/imsc1/text (or use ttp:contentProfiles for IMSC 1.1); a receiver that cannot match the designator treats the track as unknown and drops it.
Percentage where cell units are expected (and vice versa)
: Root cause: font sizes or region extents mix px, c, and % inconsistently, so text renders at the wrong scale or the region has zero area. Fix: express region tts:origin/tts:extent as percentages of the root and font sizes in c (cells) or % against the 32 15 grid; never give the root container a pixel extent.
Namespace typo silently strips styling
: Root cause: a mistyped tts or ttp URI (a #styling vs #parameter swap, a missing #) binds attributes to a namespace no processor recognises, so they are ignored without error. Fix: define the namespace URIs once as constants, build every attribute with etree.QName, and assert root.nsmap in verification.
Dangling region or style reference
: Root cause: a p references a region/style id that was renamed or never declared, so the cue falls back to the default region or disappears. Fix: validate referential integrity as in the test above — collect all xml:id values and assert every reference resolves before serializing.
Line breaks lost or doubled on conversion
: Root cause: multi-line cues encoded with literal \n in p.text instead of <br/> elements, or the source’s soft-wrap treated as a hard break. Fix: emit explicit br elements between lines (as in Step 3) and let the player wrap within the region; reserve br for authored line breaks only.
Operational Notes
Keep one document per language and reference each from the manifest as a separate subtitle rendition rather than stuffing multiple languages into one tt — segmenters and players key on the sidecar’s declared xml:lang, and a mixed-language document defeats track selection. For 29.97 and 59.94 content, always pair ttp:frameRate with ttp:frameRateMultiplier; omitting the multiplier makes frame-denominated times resolve against an integer 30/60 grid and injects sub-frame timing error that accumulates across a feature. When the same content must ship both as an in-band broadcast stream and an OTT sidecar, author the cue model once and fan out — the transport-stream path through embedding CEA-608 and CEA-708 in transport streams and the IMSC1 path here consume the identical normalized cues, so timing stays coherent across renditions. Finally, validate every document in CI with both xmllint for well-formedness and a real IMSC1 processor for profile conformance; the two catch different classes of defect, and only the pair guarantees a receiver will accept the asset.
Related
- HLS & DASH caption delivery — segmenting and referencing the IMSC1 sidecar from a manifest.
- Embedding CEA-608 & CEA-708 in transport streams — the in-band alternative to a sidecar rendition.
- WebVTT cue extraction & validation — the normalized cue model this packaging step consumes.
- TTML & IMSC1 format compliance — which TTML styling features are legal inside the IMSC1 subset.
- Converting WebVTT to IMSC1 — a focused lxml conversion of an existing WebVTT sidecar.
Part of: Caption Muxing, Packaging & Delivery — the reference for turning validated caption cues into deliverable broadcast and OTT containers.