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.

Translation contract: three formats to one canonical record to two targets SCC (hex/EIA-608) decodes through a byte-pair state machine and reaches the canonical record carrying its control state and row/column position; SRT (plaintext) decodes through a line-indexed regex and contributes timing and text only; WebVTT (cues) decodes through a header-and-cue parser and contributes CSS styling and line/position. All three converge on a single accent-highlighted canonical cue record with start_ms, end_ms, text, style, position and control_state. A target-driven router fans that record out to LINEAR_SDI (EIA-608 muxer, control codes kept, plus or minus one frame) and OTT_HLS (WebVTT packager, control codes stripped, plus or minus 50 ms). INPUT PARSER CANONICAL ROUTE TARGET SCC hex · EIA-608 SRT plaintext WebVTT cues byte-pair state machine line-indexed regex header + cue AST parse Canonical cue one ms timebase start_ms · end_ms text style position control_state Router target-driven LINEAR_SDI EIA-608 muxer control kept · ±1 frame OTT_HLS WebVTT packager control stripped · ±50 ms control + pos timing + text style + pos

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.

Part of: Broadcast Captioning Architecture & Compliance.