SCC vs SRT vs WebVTT Architecture
The ingest-and-transcode boundary is where a captioning pipeline either holds frame accuracy or quietly loses it. Three formats arrive there with incompatible internal models: SCC is a hex transport for EIA-608 byte pairs locked to a 29.97 fps drop-frame grid, SRT is editorial plaintext with millisecond timestamps and no positioning model at all, and WebVTT is a streaming-native cue format with regions, CSS styling and sub-millisecond precision. Routing all three through one encoder without reconciling those models is the root cause of phantom roll-ups, dropped cues and ±1-frame sync failures that only surface at playout. The engineering gap this page closes is the translation contract: parse each format with the strategy its structure demands, normalize every source into one canonical {start_ms, end_ms, text, style, position, control_state} record, and route that record to SDI/ATSC or OTT against a published drift budget — ±1 frame (±33.37 ms) at 29.97 fps for linear, ±50 ms for OTT.
This is the format-selection and translation layer of the broader Broadcast Captioning Architecture & Compliance framework. The acceptance gates that consume the canonical record live in the FCC Part 79 compliance checklist for the US and the Ofcom code on subtitling standards for the UK; the per-format parsers referenced below are documented in depth under the SRT, SCC & WebVTT parsing workflows reference.
Decision matrix: which format, which path
Format choice is dictated by the delivery target, not preference. SCC is the only one of the three that carries native EIA-608 control state for linear insertion; WebVTT is the only one with a positioning and styling model fit for OTT; SRT is the portable editorial interchange that must be normalized into one of the other two before it touches an encoder.
| Dimension | SCC | SRT | WebVTT |
|---|---|---|---|
| Underlying standard | EIA-608 / CEA-608 (hex transport) | de-facto SubRip | W3C WebVTT |
| Timebase | SMPTE 29.97 DF/NDF timecode | wall-clock ms (HH:MM:SS,mmm) |
wall-clock ms (HH:MM:SS.mmm) |
| Positioning | row/column on 32×15 grid | none | line/position/region |
| Styling | flash/underline/color control codes | none (markup is non-standard) | CSS-compatible cue styling |
| Control state | native (roll-up, paint-on, pop-on) | none | none |
| Native delivery target | SDI / ATSC 1.0 linear | editorial interchange only | HLS / DASH OTT |
| Parse strategy | byte-pair state machine | line-indexed regex | header + cue AST |
| Primary risk at ingest | orphaned/overlapping control codes | sub-1s cues, overlap, comma timestamps | region collisions, CSS payload bloat |
The practical rule: SCC routes to linear with EIA-608 emulation intact, WebVTT routes to OTT with styling preserved, and SRT is treated as an intermediate that gets normalized and then re-targeted. Everything else on this page implements that rule.
Pipeline stage & prerequisites
This step executes at the post-ingest, pre-mux boundary — after a file lands and before its cues are embedded into CEA-608/708 ancillary data or packaged for a CDN. It assumes raw bytes in and emits a canonical cue list out; downstream acceptance checks (sync, fidelity, placement) run on that list, and continuous live-sync measurement is delegated to automated sync drift detection. At batch scale the per-format parsers are wrapped by the async batch caption processing runner.
Required tooling:
| Tool / library | Version (tested) | Role at this stage |
|---|---|---|
| Python | 3.10+ | fractions for exact NTSC rationals, dataclasses, struct |
pysrt |
1.1.2 | SRT cue parsing when not using the regex path |
webvtt-py |
0.4.6+ | WebVTT header/region/cue parsing |
numpy |
1.26+ | Vectorized drift and overlap arithmetic across cue arrays |
charset_normalizer |
3.x | BOM/encoding detection before SRT/WebVTT decode |
pytest |
8.0+ | Boundary fixtures for each threshold |
Step-by-step implementation
Step 1 — Parse SCC as an EIA-608 byte-pair state machine
SCC is a hexadecimal representation of EIA-608 data in 2-byte pairs mapped to NTSC drop-frame timecode. Because it is a binary transport wrapper, parse it as a sequence of state transitions, not text blocks. Two constraints dominate: timecode must be derived from the exact 30000/1001 rational, and no more than the legal control-code pairs may appear on one VBI line. Detailed coverage of the full state machine lives in parsing SCC with Python libraries.
import re
from datetime import timedelta
from fractions import Fraction
# SMPTE ST 12-1 — 29.97 fps NTSC as an exact rational (never a rounded literal)
NTSC_FPS = Fraction(30000, 1001)
def parse_scc_timestamp(tc_str: str, drop_frame: bool = True) -> timedelta:
"""Convert SCC SMPTE timecode (HH:MM:SS:FF / ;FF) to a real-time timedelta."""
match = re.match(r"(\d{2}):(\d{2}):(\d{2})[:;](\d{2})", tc_str.strip())
if not match:
raise ValueError(f"Invalid SCC timecode: {tc_str!r}")
hh, mm, ss, ff = map(int, match.groups())
nominal_fps = 30 # 29.97 DF counts on a 30-frame nominal grid
total_frames = (hh * 3600 + mm * 60 + ss) * nominal_fps + ff
if drop_frame:
# SMPTE ST 12-1 drop-frame: drop frames 00,01 each minute except every 10th
total_minutes = hh * 60 + mm
total_frames -= 2 * (total_minutes - total_minutes // 10)
seconds_exact = total_frames * 1001 / 30000 # frames -> exact real-time seconds
return timedelta(seconds=float(seconds_exact))
def validate_scc_control_codes(raw_hex: str) -> bool:
"""ANSI/CTA-608-E — at most one null-pad + one data control pair per VBI line."""
words = raw_hex.upper().split()
ctrl_prefixes = ("14", "1C", "15", "1D", "94", "9C") # channel/field control-code prefixes
ctrl_words = [w for w in words if len(w) == 4 and w[:2] in ctrl_prefixes]
return len(ctrl_words) <= 2
Misaligned SCC payloads manifest as phantom roll-ups or dropped control codes during SDI insertion, directly breaking the “synchronous” requirement enforced by the FCC checklist. Control codes such as 0x9420 (Resume Direct Captioning) or 0x9425 (Roll-Up 2) must not collide within one vertical blanking interval.
Step 2 — Normalize SRT cues to broadcast-safe boundaries
SRT abandons binary transport for line-indexed plaintext with --> separators. It is portable for editorial review and localization but has no positioning, styling, or control codes, so treat it strictly as an intermediate. The two enforcement points are minimum display duration and overlap; both feed the broader SRT timestamp normalization workflow.
import re
from datetime import timedelta
from typing import List, Tuple
MIN_CUE_S = 1.0 # Ofcom/EBU readability floor — reject sub-1s display windows
def normalize_srt_cues(raw_text: str) -> List[Tuple[timedelta, timedelta, str]]:
"""Parse SRT, enforce the 1.0 s minimum dwell, and drop overlapping cues."""
cue_pattern = re.compile(
r"\d+\n"
r"(\d{2}:\d{2}:\d{2}[,\.]\d{3})\s*-->\s*(\d{2}:\d{2}:\d{2}[,\.]\d{3})\n"
r"([\s\S]*?)(?=\n\n|\Z)"
)
def to_timedelta(ts: str) -> timedelta:
ts = ts.replace(",", ".") # tolerate comma OR period ms delimiter
h, m, s_ms = ts.split(":", 2)
s, ms = s_ms.split(".")
return timedelta(hours=int(h), minutes=int(m), seconds=int(s), milliseconds=int(ms))
normalized: List[Tuple[timedelta, timedelta, str]] = []
last_end = timedelta(0)
for match in cue_pattern.finditer(raw_text):
start = to_timedelta(match.group(1))
end = to_timedelta(match.group(2))
text = match.group(3).strip()
if (end - start).total_seconds() < MIN_CUE_S: # enforce minimum dwell
end = start + timedelta(seconds=MIN_CUE_S)
if start < last_end: # drop overlap rather than stack cues
continue
normalized.append((start, end, text))
last_end = end
return normalized
Run encoding detection with charset_normalizer before this step: SRT exports frequently carry a UTF-8 BOM or Latin-1 bytes that, if decoded wrong, count toward character fidelity and corrupt timestamp parsing.
Step 3 — Validate WebVTT regions, timing and payload size
WebVTT is engineered for HTTP delivery: region definitions, CSS-compatible styling, voice tags (<v>), and sub-millisecond precision. That flexibility is the liability in a broadcast pipeline — region collisions and oversized styled payloads break deterministic cue mapping. The full extraction path is documented in WebVTT cue extraction and validation; the W3C WebVTT specification defines the cue and timestamp rules.
import re
import webvtt
from typing import Dict, List
MAX_PAYLOAD_BYTES = 4096 # typical OTT packager per-cue ceiling — guard against CSS bloat
REGION_ID = re.compile(r"^[a-zA-Z0-9_-]+$") # W3C WebVTT region identifier grammar
def validate_webvtt(vtt_path: str) -> Dict:
"""Check WebVTT cues for payload size and region-ID validity before packaging."""
captions = webvtt.read(vtt_path) # webvtt-py raises on a missing WEBVTT header
issues: List[str] = []
for cue in captions:
if len(cue.text.encode("utf-8")) > MAX_PAYLOAD_BYTES:
issues.append(f"cue at {cue.start} exceeds {MAX_PAYLOAD_BYTES} bytes")
region = getattr(cue, "region", None)
if region and getattr(region, "id", None) and not REGION_ID.match(region.id):
issues.append(f"invalid region id: {region.id!r}") # prevents CDN-side injection
return {"total_cues": len(captions), "issues": issues, "compliant": not issues}
Step 4 — Collapse to one canonical representation
The three parsers feed a single internal model — a list of dataclass instances or dicts carrying start_ms, end_ms, text, style, position, and control_state. This decouples ingest from playout: nothing downstream needs to know which format the cue came from.
from dataclasses import dataclass, field
from typing import Optional
@dataclass
class CanonicalCue:
start_ms: float
end_ms: float
text: str
style: dict = field(default_factory=dict) # WebVTT CSS / 608 flash attributes
position: Optional[dict] = None # row/col or line/position bbox
control_state: Optional[str] = None # EIA-608 mode: rollup/popon/painton
def to_canonical(start, end, text, **kw) -> CanonicalCue:
# milliseconds is the single internal timebase — convert at the parser edge, never later
return CanonicalCue(
start_ms=start.total_seconds() * 1000.0 if hasattr(start, "total_seconds") else float(start),
end_ms=end.total_seconds() * 1000.0 if hasattr(end, "total_seconds") else float(end),
text=text, **kw,
)
Step 5 — Route the canonical cues to a delivery target
Routing is deterministic and target-driven: linear SDI keeps control codes and a ±1-frame budget, OTT strips control codes and tolerates ±50 ms, archival mezzanine preserves everything at a loose tolerance. The structural detail of where this conversion trips the acceptance gates is covered in the FCC Part 79 compliance checklist.
from enum import Enum
from typing import Any, Dict, List
class DeliveryTarget(Enum):
LINEAR_SDI = "sdi_atsc"
OTT_HLS = "hls_dash"
ARCHIVAL = "mezzanine"
ROUTING = {
DeliveryTarget.LINEAR_SDI: {"encoder": "eia608_muxer", "control_code_mode": "strict", "max_drift_ms": 33.37}, # FCC §79.1 — ±1 frame @ 29.97
DeliveryTarget.OTT_HLS: {"encoder": "webvtt_packager", "control_code_mode": "strip", "max_drift_ms": 50.0}, # OTT readability budget
DeliveryTarget.ARCHIVAL: {"encoder": "srt_exporter", "control_code_mode": "preserve", "max_drift_ms": 100.0}, # lossless interchange
}
def route_caption_payload(cues: List[Dict[str, Any]], target: DeliveryTarget) -> Dict[str, Any]:
config = ROUTING[target]
out = cues
if config["control_code_mode"] == "strip": # OTT cannot carry EIA-608 control state
out = [c for c in cues if not c.get("control_state")]
return {"target": target.value, "config": config, "cues": out}
Threshold reference table
Every limit the parsers and router read lives here, not in prose, so re-pointing the pipeline at another jurisdiction or timebase is a config swap.
| Parameter | Value | Applies to | Spec clause |
|---|---|---|---|
| Frame duration (29.97) | 33.3667 ms | SCC / linear | SMPTE ST 12-1 |
| Linear sync budget | ±1 frame (±33.37 ms) | SCC → SDI/ATSC | FCC 47 CFR § 79.1(j) |
| OTT sync budget | ±50 ms | WebVTT → HLS/DASH | OTT readability practice |
| Minimum cue dwell | 1.0 s | SRT / WebVTT | Ofcom / EBU readability |
| Max overlap before drop | 50 ms | all formats | readability practice |
| Max chars per line (608) | 32 | SCC | ANSI/CTA-608-E |
| Caption grid (608) | 32 cols × 15 rows | SCC | ANSI/CTA-608-E |
| WebVTT cue payload ceiling | 4096 bytes | WebVTT | OTT packager limit |
| Control-code pairs per VBI line | ≤ 2 | SCC | ANSI/CTA-608-E |
| Quarantine pass-rate floor | 99.5% | batch QC | internal QC policy |
Verification & test pattern
Lock each boundary behind a pytest fixture so a loosened limit fails CI rather than shipping a non-compliant transcode. Test both sides of every threshold — exactly at the limit must pass, one unit beyond must fail.
import pytest
from transcode import (
parse_scc_timestamp, validate_scc_control_codes,
normalize_srt_cues, route_caption_payload, DeliveryTarget,
)
def test_scc_dropframe_timecode_is_rational():
# 00:01:00;02 DF must map to ~60.0 s real time, not 60.06 (proves drop-frame applied)
secs = parse_scc_timestamp("00:01:00;02", drop_frame=True).total_seconds()
assert abs(secs - 60.0) < 0.01
def test_scc_third_control_pair_rejected():
assert validate_scc_control_codes("9420 9425 942C") is False # > 2 control pairs on a line
def test_srt_sub_second_cue_extended_to_floor():
srt = "1\n00:00:01,000 --> 00:00:01,400\nHi\n"
start, end, _ = normalize_srt_cues(srt)[0]
assert (end - start).total_seconds() == pytest.approx(1.0) # dwell floor enforced
def test_ott_route_strips_control_state():
cues = [{"text": "x", "control_state": "rollup"}, {"text": "y"}]
out = route_caption_payload(cues, DeliveryTarget.OTT_HLS)
assert len(out["cues"]) == 1 and out["config"]["max_drift_ms"] == 50.0
Troubleshooting / failure modes
Phantom roll-up rows appear after SDI insertion
: Root cause: orphaned or duplicated EIA-608 control codes (e.g. two 0x9425 pairs in one VBI line) surviving ingest. Fix: run validate_scc_control_codes per line and reject before mux; extend with a full state-machine check from parsing SCC with Python libraries.
SRT timecodes parse correctly but cues land a frame early/late
: Root cause: SRT is wall-clock ms while the target is drop-frame; treating the ms value as if it were on a 30 fps grid skews onsets. Fix: convert to canonical start_ms then re-derive frames from Fraction(30000, 1001) at the encoder edge.
WebVTT validates locally but the packager rejects cues
: Root cause: styled cues exceed the 4096-byte payload ceiling, or a region ID contains characters outside the WebVTT grammar. Fix: enforce MAX_PAYLOAD_BYTES and the REGION_ID pattern at ingest, before packaging.
Character fidelity drops only on accented or non-Latin programming
: Root cause: a UTF-8 BOM or mis-detected Latin-1 SRT counted as content during normalization. Fix: detect encoding with charset_normalizer and strip the BOM before parsing, not after scoring.
OTT output silently loses positioning that SCC carried
: Root cause: EIA-608 row/column state has no direct WebVTT equivalent and was dropped at the canonical step. Fix: map 608 grid positions into WebVTT line/position during to_canonical, and re-validate the bounding box against the safe area.
Cumulative drift passes spot checks but fails over a long program : Root cause: a frame-rate mismatch (e.g. 25 fps source to 23.976 mezzanine) producing a slope, not a constant offset. Fix: measure drift as a curve via automated sync drift detection rather than a single sample.
Operational notes
At batch scale this stage is dominated by parse-and-decode cost, so stream cues lazily from each parser instead of materializing whole files — a three-hour SCC asset is tens of thousands of byte pairs, and only the canonical cue list needs to stay resident. Keep each parser stateless and idempotent so the same bytes always yield the same canonical record; that property lets the QC layer re-run quarantined assets after a fix without special-casing. Size the worker pool to roughly the physical core count, with each worker holding one file’s cue list (well under 100 MB even for feature length), and fan out through the async batch caption processing runner so a slow ffprobe reference lookup on one asset never blocks the rest.
Files scoring below the 99.5% validation pass rate should be quarantined for manual review rather than auto-corrected; emit a structured JSON verdict with the failing frame range and format so the automated QC validation and reporting layer can archive it as audit evidence. Emergency-information cues (47 CFR § 79.2) must never be silently dropped by the OTT control-code strip — flag them explicitly and carry their text through every route.
Frequently asked questions
When should I deliver SCC instead of WebVTT? SCC is the right choice only for linear SDI/ATSC 1.0 paths that need native EIA-608 control state (roll-up, paint-on, pop-on). For any HTTP-delivered HLS/DASH output, use WebVTT — it carries the positioning and styling model OTT players expect, while SCC’s control codes have no place in a CDN payload.
Can I convert SRT directly to SCC? Not safely in one hop. SRT has no positioning, styling, or control state, so a direct conversion has to synthesize all of it. Normalize SRT into the canonical cue model first, assign EIA-608 grid positions and control modes there, then emit SCC — which is exactly what the routing step does.
Why does the linear budget use ±1 frame when FCC Part 79 is read as ±2? ±2 frames is the per-asset acceptance budget at the compliance gate. The transcode stage targets a tighter ±1 frame so that downstream rounding and mux jitter still land inside the ±2-frame envelope by the time the asset reaches playout.
Is WebVTT’s sub-millisecond precision usable on a 29.97 fps linear path? No — linear insertion quantizes to the frame grid, so sub-millisecond timestamps are rounded to the nearest frame at the encoder. Preserve the precision in the canonical record for OTT, but expect it to collapse to frame resolution on the SCC route.
Related
- Parsing SCC with Python libraries — EIA-608 byte-pair state machine that backs Step 1.
- SRT timestamp normalization — Full dwell/overlap/encoding normalization for the SRT path.
- WebVTT cue extraction and validation — Region, styling and payload validation for the OTT path.
- FCC Part 79 compliance checklist — US acceptance gates the canonical record is tested against.
- Secure caption pipeline design — Zero-trust ingest boundary that sandboxes this transcode stage.