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.
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.
Related
- SCC vs SRT vs WebVTT architecture — how each chosen format is parsed, normalized and routed to its target.
- FCC Part 79 compliance checklist — the US regulatory floor that fixes the ATSC and linear branches.
- IMSC1 & TTML packaging for OTT — packaging step the DASH/CMAF and styled-HLS branches dispatch to.
- HLS & DASH caption delivery — segmentation and manifest wiring for the streaming branches.
- Choosing a caption format for OTT delivery — the streaming branch worked through by protocol and device.
- SCC to WebVTT migration checklist — migrating a legacy 608 library once WebVTT is the chosen target.
Part of: Broadcast Captioning Architecture & Compliance — the format, architecture and regulatory reference for broadcast caption pipelines.