SCC to WebVTT migration checklist
The exact failure this page prevents is a batch SCC-to-WebVTT conversion that reads 29.97 drop-frame timecode as non-drop and injects ~3.6 seconds per hour of drift — enough to push every cue in a feature past the ±50 ms OTT sync budget — while silently flattening roll-up captions into pop-on blocks so the reading cadence the original was authored for is destroyed. Migrating a legacy CEA-608 library to WebVTT for web and OTT is not a text extraction; it is a timebase conversion (drop-frame frames → exact milliseconds) plus a control-state translation (roll-up/pop-on/paint-on → WebVTT cues) that must preserve both. Work the checklist below in order, then run the converter; skipping the drop-frame or control-mode steps is what produces libraries that look converted and play wrong. This is the migration path for the WebVTT target chosen in the parent SCC vs SRT vs WebVTT format selection guide.
Migration checklist
| # | Step | Gotcha it defends against |
|---|---|---|
| 1 | Detect and normalize file encoding before parsing | A UTF-8 BOM or Latin-1 header shifts the Scenarist_SCC sniff and corrupts the first cue |
| 2 | Confirm the source framerate and drop-frame flag | 29.97 DF read as non-drop injects ~3.6 s/hr of drift |
| 3 | Convert timecode via exact Fraction(30000, 1001) |
Float seconds accumulate IEEE-754 error across thousands of cues |
| 4 | Decode 608 byte pairs to text, stripping control codes | Control codes decoded as glyphs emit garbage characters |
| 5 | Detect the control mode (roll-up vs pop-on vs paint-on) | Roll-up flattened to pop-on loses the original reading cadence |
| 6 | Map 608 mid-row italics/color codes to WebVTT tags | Mid-row style codes dropped lose emphasis and speaker cues |
| 7 | Emit WEBVTT cues with HH:MM:SS.mmm timestamps |
Comma or frame-count delimiters make the file unparseable by players |
| 8 | Re-validate cue timing against the ±50 ms OTT budget | Cumulative drift only shows over a long program, not in spot checks |
| 9 | Preserve positioning as line/position where it matters |
608 row/column state has no automatic WebVTT equivalent |
| 10 | Diff cue count and total duration against the source | A dropped or merged cue is invisible without a count/duration check |
import re
from fractions import Fraction
# SMPTE ST 12-1 — 29.97 fps NTSC as an exact rational, never a rounded 29.97 literal
NTSC = Fraction(30000, 1001)
CTRL_ROLLUP = {"9425", "9426", "94a7"} # CEA-608 Roll-Up 2/3/4
CTRL_POPON = "9420" # Resume Caption Loading (pop-on)
CTRL_PAINTON = "9429" # Resume Direct Captioning (paint-on)
CTRL_EOC = "942f" # End Of Caption (display pop-on buffer)
CTRL_EDM = "942c" # Erase Displayed Memory
CTRL_CR = "94ad" # Carriage Return (roll-up advance)
MIDROW_ITALIC = {"9126", "91ae"} # CEA-608 mid-row italics on
def tc_to_ms(tc: str, drop_frame: bool = True) -> float:
"""SCC SMPTE timecode HH:MM:SS;FF -> milliseconds via exact drop-frame math."""
m = re.match(r"(\d{2}):(\d{2}):(\d{2})[;:](\d{2})", tc.strip())
if not m:
raise ValueError(f"Invalid SCC timecode: {tc!r}")
hh, mm, ss, ff = (int(g) for g in m.groups())
frames = (hh * 3600 + mm * 60 + ss) * 30 + ff # count on the 30-frame nominal grid
if drop_frame:
# Drop 2 frames per minute except every 10th minute (SMPTE ST 12-1)
total_min = hh * 60 + mm
frames -= 2 * (total_min - total_min // 10)
return float(frames / NTSC * 1000) # exact frames -> real-time ms
def ms_to_ts(ms: float) -> str:
"""Milliseconds -> WebVTT timestamp HH:MM:SS.mmm (W3C WebVTT timestamp grammar)."""
ms = max(0, round(ms))
h, ms = divmod(ms, 3_600_000)
m, ms = divmod(ms, 60_000)
s, ms = divmod(ms, 1000)
return f"{h:02d}:{m:02d}:{s:02d}.{ms:03d}"
def decode_pairs(words: list[str]) -> tuple[str, str]:
"""Decode 608 hex pairs -> (text, mode); strip control codes, keep italics as <i>."""
text, mode, italic = [], "popon", False
for w in words:
w = w.lower()
if w in CTRL_ROLLUP:
mode = "rollup"
elif w == CTRL_POPON:
mode = "popon"
elif w == CTRL_PAINTON:
mode = "painton"
elif w in MIDROW_ITALIC:
italic = True
text.append("<i>")
elif w in (CTRL_EOC, CTRL_EDM, CTRL_CR) or w.startswith(("94", "91", "13", "10")):
continue # non-text control/PAC code: skip
else:
val = int(w, 16)
hi, lo = (val >> 8) & 0x7F, val & 0x7F # CEA-608 uses 7-bit chars
if hi >= 0x20:
text.append(chr(hi))
if lo >= 0x20:
text.append(chr(lo))
if italic:
text.append("</i>") # close emphasis before the cue ends
return "".join(text).strip(), mode
def scc_to_webvtt(scc_text: str, drop_frame: bool = True, cue_ms: float = 2000.0) -> str:
"""Convert a Scenarist SCC document to a WebVTT string, preserving timing and mode."""
hexp = re.compile(r"\b([0-9a-fA-F]{4})\b")
tcre = re.compile(r"(\d{2}:\d{2}:\d{2}[;:]\d{2})\s+(.*)")
cues, prev_start, prev_text = [], None, ""
for line in scc_text.splitlines():
line = line.strip()
if not line or line.lower().startswith("scenarist_scc"):
continue
m = tcre.match(line)
if not m:
continue
start = tc_to_ms(m.group(1), drop_frame)
text, mode = decode_pairs(hexp.findall(m.group(2)))
if not text:
continue
# Close the previous cue at this event's start (roll-up advances end the prior line)
if prev_start is not None:
end = start if start > prev_start else prev_start + cue_ms
cues.append((prev_start, end, prev_text))
prev_start, prev_text = start, text
if prev_start is not None:
cues.append((prev_start, prev_start + cue_ms, prev_text)) # last cue gets default dwell
out = ["WEBVTT", ""] # W3C WebVTT — mandatory file signature
for i, (s, e, t) in enumerate(cues, 1):
out.append(str(i))
out.append(f"{ms_to_ts(s)} --> {ms_to_ts(e)}")
out.append(t)
out.append("")
return "\n".join(out)
Code walkthrough
tc_to_ms is the load-bearing function: it converts HH:MM:SS;FF to milliseconds through integer frame counting and the exact Fraction(30000, 1001) rational, so the ~3.6 s/hour of drop-frame drift never enters the output. The semicolon delimiter is the drop-frame marker in Scenarist SCC, but the function keys off the drop_frame flag rather than the punctuation, because a mis-authored file can use : on a drop-frame asset — the framerate confirmation in checklist step 2 is what sets that flag correctly. ms_to_ts renders the WebVTT timestamp with the .mmm period delimiter the W3C WebVTT grammar mandates; emitting a comma (SRT style) or a frame count makes the file unparseable.
decode_pairs walks the hex words and separates control codes from displayable text. Roll-up (9425/9426/94a7), pop-on (9420) and paint-on (9429) control codes set the cue mode rather than emitting characters; mid-row italic codes open an <i> tag so emphasis survives into WebVTT; every other control or PAC code (prefixes 94/91/13/10) is skipped so it never decodes to a stray glyph. Only bytes at or above 0x20, masked to 7 bits per the CEA-608 character set, become text. scc_to_webvtt ties it together by closing each cue at the next event’s start — which is how roll-up carriage returns naturally bound the previous line’s display window — and falling back to a default dwell for the final cue. The result is a valid WEBVTT document whose cue count and durations can be diffed against the source per checklist step 10.
Threshold reference table
| Parameter | Value | Source / clause |
|---|---|---|
| Frame duration (29.97) | 33.3667 ms | SMPTE ST 12-1 |
| Drop-frame correction | 2 frames/min, skip every 10th | SMPTE ST 12-1 |
| NTSC rational | 30000 / 1001 | SMPTE ST 12-1 |
| OTT sync budget | ±50 ms | OTT readability practice |
| WebVTT timestamp | HH:MM:SS.mmm |
W3C WebVTT grammar |
| CEA-608 character mask | 7-bit (& 0x7F) |
ANSI/CTA-608-E |
| CEA-608 grid | 32 cols × 15 rows | ANSI/CTA-608-E |
| Default cue dwell | 2.0 s | Decoder render stability |
Edge cases & known gotchas
- Roll-up vs pop-on cadence. Roll-up captions advance line-by-line on carriage returns, while pop-on captions display a whole buffer at once on
End Of Caption. Converting roll-up to fixed 2-second pop-on cues destroys the original reading rhythm — detect the mode (checklist step 5) and let carriage returns, not a fixed dwell, bound roll-up cues. - Drop-frame math is not optional. A 29.97 DF asset decoded as non-drop drifts ~3.6 s/hour; over a 90-minute feature that is more than 5 seconds of accumulated error, far past the ±50 ms OTT budget. Always confirm the source framerate before trusting the timecode punctuation.
- Positioning loss. CEA-608 row/column PAC state has no automatic WebVTT equivalent; a naive converter drops it and every cue lands bottom-center. Where placement is meaningful (speaker separation, on-screen text avoidance), map the 608 grid row to a WebVTT
linevalue explicitly. - Italics and color mid-row codes. Mid-row style codes toggle emphasis partway through a line; if they are skipped as generic control codes the emphasis vanishes, and if they are decoded as text they emit garbage. Translate the known italic/color codes to WebVTT tags and skip the rest.
- Empty and control-only lines. SCC lines carrying only clear/erase control codes decode to empty text and must be dropped, not emitted as blank cues, or the player shows flickering empty caption regions.
Integration hook
This converter implements the SCC→WebVTT leg of the migration that the parent SCC vs SRT vs WebVTT format selection guide recommends whenever a legacy 608 library is retargeted at web or OTT. The WebVTT it produces is the input to the OTT packaging decision in choosing a caption format for OTT delivery, where it is segmented for HLS or wrapped for DASH. Where the goal is an editorial interchange rather than a web target, the same drop-frame timebase discipline is covered from the SRT angle in converting SCC to SRT without timing loss.
Related
- SCC vs SRT vs WebVTT format selection guide — the parent decision that names WebVTT as the migration target.
- Converting SCC to SRT without timing loss — the same drop-frame timebase discipline applied to an SRT target.
- Choosing a caption format for OTT delivery — how the migrated WebVTT is packaged per streaming protocol.
Part of: Broadcast Captioning Architecture & Compliance — the format, architecture and regulatory reference for broadcast caption pipelines.