SCC vs SRT vs WebVTT Format Selection Guide

Defaulting every delivery to one caption format is how a pipeline ships a file that is technically valid and operationally wrong: an SRT handed to a broadcast mux that has no line-21 path for it, a WebVTT whose CSS styling silently evaporates on a CEA-608 decoder, an IMSC1 document rejected by a set-top box that only speaks CEA-708. The decision this page systematizes is not “which format is best” — none is — but “which format and packaging does this distribution target actually accept, at this framerate, with this positioning and styling requirement, under which regulator.” Get that mapping wrong and the failure surfaces at playout or at an audit, not at build time. This is the format-and-packaging selection layer of the broader Broadcast Captioning Architecture & Compliance framework, and it feeds directly into the muxing and delivery stages that carry the chosen artifact to air or to a CDN.

Format choice is a function of six inputs — the distribution target, the source framerate, whether positioning must survive, whether styling must survive, the regulatory regime, and whether the target is a passthrough or a re-encode boundary. Fix those six and the recommendation is deterministic. This page encodes that determinism as a matrix, a decision tree, and a select_format() function you can drop into a build step, so the choice is auditable rather than a habit. Where the sibling SCC vs SRT vs WebVTT architecture reference explains how each format is parsed and routed, this page answers the prior question: which one to emit in the first place.

Distribution target drives the caption format and packaging choice A top-down decision tree. The root node "distribution target?" branches four ways. Broadcast branches to linear SDI (recommend SCC/MCC, CEA-608 plus CEA-708 control state, VANC insertion) and ATSC OTA (recommend CEA-708 in the MPEG-2 transport stream). Streaming branches to HLS (recommend segmented WebVTT, or fragmented-MP4 IMSC1) and DASH/CMAF (recommend IMSC1 or TTML in a text AdaptationSet). File download recommends SRT or a WebVTT sidecar. Archive/mezzanine recommends SCC or IMSC1 to preserve every control and styling attribute at a loose tolerance. Which format for which target The distribution target — not preference — fixes the format and its packaging. distribution target? Broadcast Streaming File download Archive Linear SDI SCC / MCC 608+708 · VANC ATSC OTA CEA-708 in TS HLS segmented VTT or fMP4 IMSC1 DASH / CMAF IMSC1 / TTML text AdaptationSet Download SRT or WebVTT sidecar Mezzanine SCC / IMSC1 full state kept Framerate, positioning, styling and regulator refine each leaf — see select_format() below. SRT never carries positioning or styling; WebVTT styling is lost on a 608 decoder; IMSC1 preserves both for OTT.

Problem Framing: The Six Inputs That Fix the Choice

A caption format is a container for three things — timing, text, and optionally positioning, styling and control state — and each distribution target accepts a different subset. Broadcast paths care about frame-locked timing and CEA-608/708 control state and ignore CSS entirely; OTT paths care about styling and positioning and have no concept of a line-21 control code. Choosing a format is therefore choosing which of those capabilities you keep and which you throw away, and the target dictates that, not editorial taste.

Six inputs make the decision deterministic. Distribution target is the dominant axis: linear SDI/broadcast, ATSC over-the-air, HLS, DASH/CMAF, VOD file download, and archive/mezzanine each have a canonical format. Source framerate decides the timebase — a 29.97 fps drop-frame source binds to SMPTE timecode and SCC, while a 25 fps or 24 fps source that will only ever stream can live on a wall-clock millisecond grid. Positioning need eliminates SRT the moment a caption must sit anywhere other than bottom-center. Styling need (italics, color, mixed fonts) eliminates SRT and constrains you to WebVTT or IMSC1/TTML. Regulatory regime sets the floor: US ATSC delivery legally requires CEA-708 with a CEA-608 compatibility path under FCC Part 79, so no amount of OTT convenience lets you ship WebVTT to an OTA transmitter. Passthrough vs re-encode decides whether an existing embedded track can be carried untouched or must be extracted and re-authored.

The quantified contract the rest of this page enforces: zero positioning loss on a target that requires positioning, zero styling loss on a target that requires styling, a timebase whose rounding stays inside the target’s sync budget (±1 frame ≈ 33.37 ms for 29.97 linear, ±50 ms for OTT), and a regulatory format match with no exceptions on OTA paths.

Decision Matrix: Format Against Target

Rows are the caption representations you can emit; columns are the distribution targets plus the three capability axes that break ties. A cell reads “native” (the format is the canonical carrier), “viable” (works but with caveats), or “wrong” (technically or legally unfit). Read down a target column to see the shortlist; read across a row to see where a format legitimately lives.

Format / representation Linear SDI ATSC OTA HLS DASH / CMAF VOD download Archive / mezzanine Timebase Positioning Styling Regulatory fit
SCC / MCC native via 708 upconvert wrong wrong wrong native SMPTE 29.97 DF 32×15 grid flash/underline/color FCC 608 baseline
SRT wrong wrong viable (sidecar) wrong native viable wall-clock ms none none editorial only
WebVTT wrong wrong native (segmented) viable native viable wall-clock ms line/position/region CSS cue styling OTT / EU catch-up
IMSC1 / TTML wrong wrong viable (fMP4) native viable native media-time SMPTE region + extent full TTML styling IMSC1 OTT + SMPTE-TT
CEA-608 native (line 21) mandatory baseline via extract via extract wrong native line-21 field 32×15 grid limited color/flash FCC 47 CFR §79.1
CEA-708 native (708 svc) mandatory (primary) via extract via extract wrong native DTVCC packets window model full 708 pens/windows FCC / CTA-708

Three rules fall straight out of the matrix. First, nothing in the broadcast columns is streamable and nothing in the streaming columns is broadcastable without a format change — the diagonal is empty for a reason. Second, SRT is never a delivery format for a regulated path; it is an editorial interchange that is normalized into one of the others, exactly as covered in the architecture reference. Third, IMSC1/TTML is the only representation that spans OTT delivery and mezzanine archive while preserving both positioning and styling, which is why it is the pivot format for the IMSC1 & TTML packaging for OTT workflow.

Pipeline Stage & Prerequisites

Format selection runs once, at the top of the packaging fan-out: after the canonical cue model exists but before any target-specific muxer or packager is invoked. It is a pure decision step — it emits a recommendation record, not bytes — so it is cheap to run for every target in a delivery matrix and cheap to unit-test. The muxers it dispatches to live downstream in HLS & DASH caption delivery and the IMSC1 packaging workflow; the selector’s only job is to name the correct one.

Required tooling:

Tool / Library Version Role in selection
Python ≥ 3.10 dataclasses, enum, structural pattern matching
pycaption ≥ 1.0.6 Confirms a source can be read as the recommended format
ffprobe (FFmpeg) ≥ 4.4 Detects source framerate and any embedded 608/708 track
lxml ≥ 4.9 Validates IMSC1/TTML output when that branch is chosen
pytest ≥ 8.0 Locks each decision branch behind a fixture

The selector reads framerate and embedded-track presence from ffprobe output; it does not re-decode media. For an OTA target it must be able to confirm a CEA-708 service exists or can be synthesized, because the regulatory branch treats a missing 708 service on an ATSC path as a hard failure, not a fallback to 608.

Step-by-Step Implementation

Step 1 — Model the targets and the recommendation

Enumerate the distribution targets and define the record the selector returns. Making the recommendation a dataclass — not a bare string — means every downstream consumer gets the format, the container, the packaging notes, the timebase and the rationale in one auditable object.

from dataclasses import dataclass
from enum import Enum

class Target(Enum):
    LINEAR_SDI = "linear_sdi"     # SDI/baseband broadcast, VANC/line-21 insertion
    ATSC_OTA = "atsc_ota"         # ATSC 1.0 over-the-air transmission
    HLS = "hls"                   # Apple HTTP Live Streaming
    DASH_CMAF = "dash_cmaf"       # MPEG-DASH / CMAF adaptive streaming
    VOD_DOWNLOAD = "vod_download" # progressive file download / sidecar
    ARCHIVE = "archive"           # mezzanine / long-term preservation

@dataclass
class Recommendation:
    fmt: str          # caption representation to emit
    container: str    # how it is wrapped for the target
    packaging: str    # concrete packaging notes for the muxer step
    timebase: str     # timing model the format must carry
    rationale: str    # the engineering/regulatory reason, for the audit log

Step 2 — Encode the regulatory and broadcast branches

Broadcast targets are the least negotiable, so resolve them first. An ATSC over-the-air path is legally bound to carry CEA-708 as the primary service with a CEA-608 compatibility path, per FCC 47 CFR § 79.1 and the CTA-708 carriage rules — positioning and styling requests do not change the format here, they change the authoring inside it.

def _broadcast_branch(target: Target, needs_positioning: bool,
                      needs_styling: bool, framerate: float) -> Recommendation:
    df = abs(framerate - 29.97) < 0.01  # SMPTE ST 12-1 drop-frame applies near 29.97
    tb = "SMPTE 29.97 drop-frame" if df else f"SMPTE {framerate:g} non-drop"

    if target is Target.ATSC_OTA:
        # FCC 47 CFR § 79.1 — 708 primary + 608 compatibility is mandatory, not optional
        return Recommendation(
            fmt="CEA-708 (+CEA-608 compat)",
            container="MPEG-2 transport stream (DTVCC in picture user_data)",
            packaging="Embed 708 service #1; carry 608 field-1 for legacy decoders.",
            timebase=tb,
            rationale="ATSC OTA legally requires 708 primary with a 608 path (47 CFR §79.1).",
        )

    # LINEAR_SDI — SCC/MCC carries native 608/708 control state into VANC/line 21
    fmt = "MCC" if needs_styling or needs_positioning else "SCC"
    return Recommendation(
        fmt=fmt,
        container="line-21 (608) / VANC SMPTE ST 334 (708)",
        packaging="Insert as ancillary data at ±1 frame; keep roll-up/pop-on control state.",
        timebase=tb,
        rationale="Linear SDI needs native 608/708 control state; SCC/MCC is the only carrier.",
    )

Step 3 — Encode the streaming and file branches

Streaming targets split on protocol. HLS carries WebVTT as time-segmented sidecars or, since fragmented-MP4 support matured, IMSC1 in a wvtt/stpp track; DASH prefers IMSC1/TTML in a dedicated text AdaptationSet. Styling is the tie-breaker: a request for anything richer than italic underlines the IMSC1 branch because WebVTT’s CSS parity across players is unreliable.

def _streaming_branch(target: Target, needs_positioning: bool,
                      needs_styling: bool) -> Recommendation:
    if target is Target.HLS:
        if needs_styling:
            # IMSC1 in fMP4 gives deterministic styling HLS WebVTT cannot guarantee cross-player
            return Recommendation(
                fmt="IMSC1", container="fragmented MP4 (stpp)",
                packaging="Segment to the media segment duration; declare in the master playlist.",
                timebase="media-time (SMPTE-TT)",
                rationale="Rich styling needs IMSC1; HLS fMP4 subtitles carry it deterministically.",
            )
        return Recommendation(
            fmt="WebVTT", container="segmented WebVTT sidecar",
            packaging="Split cues on segment boundaries; X-TIMESTAMP-MAP anchors to MPEG-TS PTS.",
            timebase="wall-clock ms (W3C WebVTT)",
            rationale="HLS natively serves segmented WebVTT; positioning survives, styling is basic.",
        )

    # DASH / CMAF — IMSC1/TTML in a text AdaptationSet is the canonical carrier
    return Recommendation(
        fmt="IMSC1 (TTML profile)", container="ISOBMFF text track / sidecar TTML",
        packaging="Declare a text AdaptationSet; one Representation per language.",
        timebase="media-time (SMPTE-TT)",
        rationale="DASH carries IMSC1 in a text AdaptationSet with full region + styling.",
    )

def _file_branch(needs_positioning: bool, needs_styling: bool) -> Recommendation:
    if needs_positioning or needs_styling:
        # SRT has no positioning or styling model at all — escalate to WebVTT
        return Recommendation(
            fmt="WebVTT", container="sidecar .vtt",
            packaging="Ship alongside the media; players read line/position and cue CSS.",
            timebase="wall-clock ms (W3C WebVTT)",
            rationale="Download that needs placement or styling cannot use SRT; WebVTT carries both.",
        )
    return Recommendation(
        fmt="SRT", container="sidecar .srt",
        packaging="Plain SubRip; widest player compatibility for simple bottom-center text.",
        timebase="wall-clock ms",
        rationale="Simple download captions: SRT maximizes player compatibility.",
    )

Step 4 — Compose the selector

The public entry point dispatches on target class and lets the branch functions apply the framerate, positioning and styling refinements. Archive is its own branch because it optimizes for preservation of every attribute rather than for a player, so it keeps SCC for a 608-origin asset and IMSC1 for a styled OTT-origin asset.

def select_format(target: Target, needs_positioning: bool = False,
                  needs_styling: bool = False, framerate: float = 29.97) -> Recommendation:
    """Rule-based caption format selector. Deterministic for a fixed set of inputs."""
    if target in (Target.LINEAR_SDI, Target.ATSC_OTA):
        return _broadcast_branch(target, needs_positioning, needs_styling, framerate)
    if target in (Target.HLS, Target.DASH_CMAF):
        return _streaming_branch(target, needs_positioning, needs_styling)
    if target is Target.VOD_DOWNLOAD:
        return _file_branch(needs_positioning, needs_styling)
    if target is Target.ARCHIVE:
        # Mezzanine keeps whatever preserves the most state: styled -> IMSC1, else SCC
        if needs_styling or needs_positioning:
            return Recommendation(
                fmt="IMSC1", container="standalone TTML document",
                packaging="Preserve region, extent and styling; loose timing tolerance.",
                timebase="media-time (SMPTE-TT)",
                rationale="Styled archive keeps IMSC1 so no positioning/styling is lost.",
            )
        return Recommendation(
            fmt="SCC", container="Scenarist SCC file",
            packaging="Preserve 608 control state and drop-frame timecode verbatim.",
            timebase="SMPTE 29.97 drop-frame",
            rationale="608-origin archive keeps SCC to preserve native control state.",
        )
    raise ValueError(f"Unhandled target: {target}")

Each branch names the muxer or packager the packaging stage will call, so the recommendation is directly actionable: an HLS result routes to WebVTT segmentation or IMSC1 fragmentation in HLS & DASH caption delivery, while a DASH_CMAF result routes to the IMSC1 & TTML packaging workflow.

Threshold Reference Table

Every numeric limit and hard constraint the selector and its downstream muxers enforce, with its source. Tune against this table, never against prose.

Constraint Value Applies to Source / clause
Linear sync budget ±1 frame (±33.37 ms @ 29.97) SCC/MCC → SDI FCC 47 CFR § 79.1(j)
OTT sync budget ±50 ms WebVTT/IMSC1 → HLS/DASH OTT readability practice
Frame duration (29.97) 33.3667 ms drop-frame timebase SMPTE ST 12-1
ATSC caption requirement CEA-708 primary + 608 path ATSC OTA FCC 47 CFR § 79.1 / CTA-708
CEA-608 grid 32 cols × 15 rows SCC / 608 ANSI/CTA-608-E
WebVTT cue payload ceiling 4096 bytes WebVTT → OTT packager OTT packager limit
Minimum cue dwell 1.0 s SRT / WebVTT Ofcom / EBU readability
IMSC1 styling profile text + image profiles IMSC1/TTML W3C IMSC 1.1
HLS subtitle segment = media segment duration segmented WebVTT Apple HLS authoring spec

Regulatory framing for the ATSC row — why 708-primary is non-negotiable and how a 608 compatibility path is proven — is detailed in the FCC Part 79 compliance checklist.

Verification & Test Pattern

A selector is only trustworthy if every branch is pinned by a test that fails when the mapping drifts. Assert on the format and rationale, not just the format string, so a refactor cannot silently change why a choice was made.

import pytest
from selector import select_format, Target

def test_atsc_is_always_708_regardless_of_styling():
    # Regulatory branch must not be overridden by an OTT-style styling request
    rec = select_format(Target.ATSC_OTA, needs_styling=True)
    assert rec.fmt.startswith("CEA-708")
    assert "79.1" in rec.rationale

def test_hls_styling_escalates_to_imsc1():
    rec = select_format(Target.HLS, needs_styling=True)
    assert rec.fmt == "IMSC1" and "fMP4" in rec.container

def test_hls_plain_is_segmented_webvtt():
    rec = select_format(Target.HLS)
    assert rec.fmt == "WebVTT" and "segmented" in rec.container

def test_download_positioning_rejects_srt():
    rec = select_format(Target.VOD_DOWNLOAD, needs_positioning=True)
    assert rec.fmt == "WebVTT"  # SRT has no positioning model

def test_linear_keeps_native_control_state():
    rec = select_format(Target.LINEAR_SDI, framerate=29.97)
    assert rec.fmt in ("SCC", "MCC") and "drop-frame" in rec.timebase

def test_unknown_target_raises():
    with pytest.raises(ValueError):
        select_format("broadcast")  # not a Target member

Run this suite in the same build step that invokes the selector so a mapping regression fails the pipeline instead of shipping the wrong format.

Troubleshooting / Failure Modes

SRT delivered to a broadcast mux : Root cause: treating SRT as a universal format and handing it to an SDI/ATSC path that has no line-21 or VANC route for plaintext. SRT carries no CEA-608 control state, so the mux either rejects it or emits a caption-less stream. Fix: never route SRT to a broadcast target — normalize it into the canonical model and emit SCC/MCC via the architecture routing step.

WebVTT styling lost on a CEA-608 decoder : Root cause: authoring color and positioning as WebVTT cue CSS, then downconverting to 608 for a legacy decoder that only supports the limited 608 color/flash set. The CSS silently drops. Fix: if a 608 decoder is in the target set, author styling within the 608 capability envelope and treat WebVTT CSS as OTT-only enrichment.

IMSC1 shipped to a set-top box that only speaks CEA-708 : Root cause: choosing IMSC1 for an OTA-adjacent device on the assumption that TTML is universal. A 708-only decoder cannot parse IMSC1. Fix: gate IMSC1 to OTT targets; for any device on the broadcast side, emit CEA-708 and let the decoder targeting analysis confirm the decoder class.

Drop-frame SCC re-timed as non-drop for OTT : Root cause: extracting a 29.97 drop-frame SCC track and emitting WebVTT with the timecode read as non-drop, injecting ~3.6 s/hour of drift past the ±50 ms OTT budget. Fix: convert through the exact Fraction(30000, 1001) timebase before emitting wall-clock milliseconds.

WebVTT chosen for DASH where IMSC1 was expected : Root cause: defaulting streaming to WebVTT without checking the packager’s AdaptationSet requirements; some DASH packagers only accept IMSC1/TTML text tracks. Fix: let the DASH_CMAF branch pick IMSC1 and route it through the IMSC1 & TTML packaging for OTT workflow.

Forced-narrative subtitles collapsed into the main caption track : Root cause: emitting one track for both full captions and forced-narrative (foreign-dialogue) subtitles, so a player cannot present forced-only. Fix: author forced narratives as a distinct track/Representation with the forced flag set, decided at selection time, not after packaging.

Operational Notes

Run select_format() once per (asset, target) pair at the head of the delivery matrix and persist the returned Recommendation — format, container, timebase, rationale — into the build’s audit record. That record is the evidence that a format choice was made deliberately and matched the regulatory regime, which is precisely what an FCC Part 79 inquiry expects to see. Because the selector is pure, the same six inputs always yield the same recommendation, so a delivery that failed review can be re-derived and re-explained without re-running the pipeline.

When a single source fans out to many targets, cache the framerate and embedded-track probe once and pass it to every select_format() call rather than re-running ffprobe per target. Keep the regulatory branches at the top of the dispatch so an OTA path can never be overridden by a convenience request, and treat any target the selector does not recognize as a hard error rather than a silent default — an unhandled target is exactly the situation where a pipeline would otherwise fall back to “whatever format we had,” which is the failure this page exists to prevent.

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