Choosing a caption format for OTT delivery

The exact failure this page prevents is shipping one caption format to every streaming target and discovering at playout that HLS players ignore the DASH-style TTML sidecar, or that a low-latency CMAF packager rejects a monolithic WebVTT file that was never segmented to the media-segment duration. OTT format choice is decided by the streaming protocol and device matrix, not by a house default: HLS natively serves segmented WebVTT (or fragmented-MP4 IMSC1), DASH/CMAF carries IMSC1/TTML in a dedicated text AdaptationSet, and Apple platforms impose additional rules that a generic packager will not enforce for you. Choose against the protocol and the recommendation is deterministic; choose against habit and the caption track is silently absent on a subset of clients. This page is the streaming branch of the parent SCC vs SRT vs WebVTT format selection guide worked through protocol by protocol.

from dataclasses import dataclass
from enum import Enum

class Protocol(Enum):
    HLS = "hls"                 # Apple HTTP Live Streaming
    DASH = "dash"               # MPEG-DASH (ISOBMFF text tracks)
    CMAF_LL = "cmaf_low_latency"  # Low-latency CMAF (chunked)

@dataclass
class OTTChoice:
    fmt: str        # caption representation
    container: str  # how it is wrapped
    packaging: str  # concrete packager notes
    notes: str      # protocol/device caveat for the audit log

def choose_ott_caption(protocol: Protocol, needs_styling: bool = False,
                       apple_target: bool = False, forced_narrative: bool = False,
                       languages: int = 1) -> OTTChoice:
    """Map a streaming protocol + device matrix to a caption format and container."""
    if protocol is Protocol.HLS:
        if needs_styling:
            # IMSC1 in fMP4 gives cross-player styling parity WebVTT CSS cannot guarantee
            fmt, container = "IMSC1", "fragmented MP4 (stpp)"
            packaging = "Segment to the media segment duration; one EXT-X-MEDIA SUBTITLES group."
        else:
            # HLS serves WebVTT split on segment boundaries, anchored by X-TIMESTAMP-MAP
            fmt, container = "WebVTT", "segmented WebVTT sidecar"
            packaging = ("Split cues at segment boundaries; each part carries "
                         "X-TIMESTAMP-MAP=MPEGTS:900000,LOCAL:00:00:00.000.")
        note = "Apple requires a SUBTITLES group per language in the master playlist."
    elif protocol is Protocol.DASH:
        # DASH text AdaptationSet carries IMSC1/TTML with full region + styling
        fmt, container = "IMSC1 (TTML profile)", "ISOBMFF text track (stpp)"
        packaging = "One text AdaptationSet; one Representation per language, @lang set."
        note = "Use image profile only for burned pre-rendered subtitles."
    else:  # CMAF_LL
        # Low-latency chunked CMAF needs the caption track chunked on the same cadence
        fmt, container = "IMSC1", "CMAF chunked text track"
        packaging = "Chunk captions on the same chunk duration as A/V to hold end-to-end latency."
        note = "Monolithic sidecars break low-latency; the text track must be chunked too."

    if apple_target and fmt == "WebVTT":
        # Apple HLS authoring: CLOSED-CAPTIONS vs SUBTITLES characteristic must be explicit
        note += " Set CHARACTERISTICS public.accessibility.transcribes-spoken-dialog."
    if forced_narrative:
        # Forced narratives must be a distinct track flagged FORCED=YES, never merged
        packaging += " Emit forced-narrative as a separate FORCED=YES Representation."
    if languages > 1:
        packaging += f" Author {languages} parallel tracks; one per BCP-47 language tag."

    return OTTChoice(fmt=fmt, container=container, packaging=packaging, notes=note)

Code walkthrough

choose_ott_caption dispatches on Protocol first because the protocol is the dominant axis — it determines the container before any styling or device refinement applies. The HLS branch splits on needs_styling: plain captions become segmented WebVTT, the format HLS was designed to serve, where cues are physically cut at each media-segment boundary and every segment carries an X-TIMESTAMP-MAP that anchors the WebVTT wall-clock zero to the MPEG-TS presentation timestamp (the 900000 value is 10 seconds at the 90 kHz PTS clock). When rich styling is required, the branch escalates to IMSC1 in fragmented MP4, because WebVTT cue CSS renders inconsistently across the player matrix and IMSC1’s TTML styling is deterministic.

The DASH branch always selects IMSC1/TTML carried as an ISOBMFF text track inside a dedicated text AdaptationSet, with one Representation per language and the @lang attribute set so the player’s language menu is populated correctly. The low-latency CMAF branch is the subtle one: it also picks IMSC1 but forces the caption track to be chunked on the same cadence as audio and video. A monolithic subtitle file, however valid, cannot be delivered in sub-segment chunks, so it stalls the low-latency contract — the caption track has to be produced as CMAF chunks alongside the media.

The three refinement blocks apply orthogonally. apple_target adds the accessibility characteristic Apple’s HLS authoring specification expects so the track is announced as transcribing spoken dialog rather than as a generic subtitle. forced_narrative forces a separate FORCED=YES representation — foreign-dialogue subtitles must never be merged into the full caption track, or a viewer cannot select forced-only. languages > 1 fans the choice out to one track per BCP-47 tag, which is what lets the player build a language selector. The returned OTTChoice carries the packager notes and caveats into the build’s audit log so the decision is reconstructable.

The device matrix is why the styling branch matters more than it first appears. A caption track that renders identically on a desktop browser, a smart-TV app, and a set-top box is only guaranteed when the styling model is deterministic, and WebVTT cue CSS is not — line breaks, background boxes and positioning are interpreted differently across the player field, so a caption that looks correct in one client can overflow the safe area in another. IMSC1’s TTML styling is specified tightly enough that the same document renders the same everywhere it is accepted, which is why the HLS branch escalates to it the moment styling is load-bearing rather than cosmetic. The trade is reach: not every legacy HLS client parses fragmented-MP4 IMSC1, so when the device matrix still includes old iOS or first-generation smart-TV decoders, segmented WebVTT with styling kept inside the safe subset remains the pragmatic choice, and the richer IMSC1 rendition is offered as an additional SUBTITLES group rather than the only one.

CEA-608 passthrough is the branch the function deliberately leaves in-band. When the mezzanine already carries an embedded 608 track, the correct OTT choice for that stream is often to declare it as an in-band CLOSED-CAPTIONS attribute on the variant rather than to emit any sidecar at all — the caption data rides inside the video, and the player exposes it as a caption option. Emitting a WebVTT sidecar and passing through the in-band 608 is the common mistake: the client then lists two captions for one language and viewers pick unpredictably between them.

Threshold reference table

Parameter Value Source / clause
OTT sync budget ±50 ms OTT readability practice
HLS subtitle segment = media segment duration Apple HLS authoring spec
X-TIMESTAMP-MAP anchor MPEGTS:900000 = 10 s @ 90 kHz HLS / MPEG-TS PTS clock
WebVTT cue payload ceiling 4096 bytes OTT packager limit
IMSC1 profile text profile (image only if burned) W3C IMSC 1.1
Language tags BCP-47 per track RFC 5646 / DASH @lang
Forced-narrative flag FORCED=YES / forced Representation HLS / DASH forced subtitles
Low-latency chunk cadence = A/V chunk duration Low-latency CMAF / HLS

Edge cases & known gotchas

  • Apple requires a SUBTITLES group, not a bare sidecar. An HLS master playlist must declare an EXT-X-MEDIA:TYPE=SUBTITLES group per language with AUTOSELECT/DEFAULT set; a WebVTT file referenced without the group is invisible in the player’s caption menu even though it downloads fine.
  • Forced subtitles are a track, not a cue attribute. Foreign-dialogue “forced narratives” must be authored as a distinct FORCED=YES Representation so a viewer watching in the original language sees only the translated dialogue, not the full caption set.
  • WebVTT vs IMSC1 styling parity is not one-to-one. WebVTT positions with line/position/align and styles with cue CSS; IMSC1 uses region extent/origin and TTML styling. Round-tripping between them drops attributes that have no counterpart, so pick one styling model per delivery rather than converting late.
  • CEA-608 passthrough belongs in-band, not as a sidecar. When the mezzanine already carries an embedded CEA-608 track, the HLS/CMAF packager can pass it through as in-band CLOSED-CAPTIONS declared in the variant stream — do not also emit a duplicate WebVTT sidecar, or clients show two caption options for one track.
  • Low-latency and monolithic subtitles are incompatible. A single .vtt or TTML file defeats chunked delivery; the text track must be segmented/chunked on the same cadence as the media or it becomes the tail latency of the whole stream.

Integration hook

This function is the concrete OTT expansion of the streaming branch in the parent SCC vs SRT vs WebVTT format selection guide: where the parent’s select_format() returns “WebVTT segmented” or “IMSC1” for a streaming target, this refines that call by protocol, device and forced-narrative flags before the packager runs. The OTTChoice it returns is handed straight to the segmentation and manifest wiring in HLS & DASH caption delivery, and a WebVTT result that originated from a legacy SCC library should first pass through the SCC to WebVTT migration checklist so its drop-frame timing survives the conversion.

Part of: Broadcast Captioning Architecture & Compliance — the format, architecture and regulatory reference for broadcast caption pipelines.