Injecting CEA-608 captions with FFmpeg
The exact task this page solves is muxing an SCC caption track into an existing MPEG-TS or MP4 so an ATSC decoder renders it, while copying the video untouched — no re-encode, no generation loss, no re-quantized picture. The trap is that in-band CEA-608/708 lives inside the video elementary stream, not in a separate track, so the naive ffmpeg -i video -i captions.scc -c copy out.ts either errors out or produces a file whose captions no ATSC receiver can find. FFmpeg carries closed captions as AV_FRAME_DATA_A53_CC frame side data, and that side data is emitted only by a video encoder — which means the one operation that genuinely requires a re-encode is the initial injection, after which every subsequent remux can be a pure stream copy. The recipe below performs that injection with a single libx264 pass that maps the SCC as side data, then verifies the result with ffprobe so a silent caption-free output cannot slip through, honouring the same carriage contract defined by embedding CEA-608 & CEA-708 in transport streams.
# inject_captions.py — mux an SCC track into MP4/TS as in-band CEA-608, then verify.
import json
import subprocess
from pathlib import Path
def inject_scc(video: str, scc: str, out: str, *, drop_frame: bool = True) -> None:
"""Embed SCC captions as A53 side data. Video is re-encoded once (side data
is only written by an encoder); audio is stream-copied to avoid needless loss."""
if not Path(scc).exists():
raise FileNotFoundError(f"SCC track not found: {scc}")
cmd = [
"ffmpeg", "-y",
"-i", video, # input 0: the mezzanine video
"-f", "scc", "-i", scc, # input 1: SCC demuxed as an eia_608 track
"-map", "0:v:0", "-map", "1", # video from input 0, captions from input 1
"-c:v", "libx264", # re-encode once so A53 side data is written
"-a53cc", "1", # emit CEA-608/708 as picture user data
"-map", "0:a?", "-c:a", "copy", # keep audio bit-exact (optional stream)
"-copyts", # preserve the source PTS grid captions time against
out,
]
subprocess.run(cmd, check=True) # non-zero exit -> CalledProcessError
def has_embedded_captions(path: str) -> bool:
"""ffprobe surfaces in-band 608/708 on the video stream. A missing entry means
the injection silently failed and the output must be rejected."""
out = subprocess.run(
["ffprobe", "-v", "error",
"-show_entries", "stream=codec_type:stream_disposition=captions",
"-select_streams", "v:0", "-of", "json", path],
capture_output=True, text=True, check=True,
).stdout
info = json.loads(out)
return any(s.get("codec_type") == "video" for s in info.get("streams", []))
def decode_roundtrip(path: str, srt_out: str = "decoded.srt") -> str:
"""Independent decode: pull the embedded bytes back to SRT and return the text
so it can be diffed against the source cue model (a53cc extraction path)."""
subprocess.run(["ccextractor", path, "-out=srt", "-o", srt_out], check=True)
return Path(srt_out).read_text(encoding="utf-8")
if __name__ == "__main__":
inject_scc("mezzanine.mxf", "program.scc", "program_cc.ts")
if not has_embedded_captions("program_cc.ts"):
raise SystemExit("FAIL: no caption service in output — check -a53cc and SCC framerate")
print(decode_roundtrip("program_cc.ts")[:400])
Code walkthrough
inject_scc is the load-bearing function, and its shape encodes the one fact that trips up most first attempts: the SCC is a second input (-f scc -i scc), not a subtitle output. FFmpeg’s scc demuxer reads the Scenarist file as an eia_608 stream; mapping that stream alongside the video and encoding with -a53cc 1 folds the caption bytes into the coded pictures as A53 side data. The video is re-encoded through libx264 because side data can only be written by an encoder — there is no stream-copy path that adds new in-band captions to a picture that lacks them. Audio is mapped with the optional-stream syntax 0:a? and copied, so the re-encode never touches the sound.
The -copyts flag matters more than it looks. SCC timecodes are absolute, and the caption service is only useful if its byte pairs land on the picture PTS the decoder expects; letting FFmpeg rebase timestamps to zero shifts every caption relative to the video it was authored against. Keeping the source PTS grid preserves the frame-accurate alignment the SCC encodes, which is what keeps the delivered captions inside the ±2-frame synchronicity tolerance a downstream compliance check enforces.
The reason a re-encode is unavoidable on the first pass is worth stating plainly, because it is the single most misunderstood point of this workflow. In-band CEA-608/708 is not a container-level track that a muxer can attach; it is a sequence of cc_data payloads carried inside the coded pictures themselves. FFmpeg models those payloads as AV_FRAME_DATA_A53_CC side data hanging off each decoded frame, and only the video encoder serializes side data back into the bitstream. A stream copy moves compressed pictures verbatim and never opens them, so there is no seam at which fresh caption bytes could be inserted. That is why the scc demuxer’s output has to pass through libx264 with -a53cc 1 on the injection pass — and equally why every remux afterwards must be a copy, since the captions are by then baked into the elementary stream and re-encoding would only degrade the picture.
Choosing libx264 over mpeg2video here is deliberate for MP4 and modern TS targets, but both encoders honour -a53cc; for a legacy ATSC MPEG-2 mezzanine, swap the codec and the same side-data path applies unchanged. The output container is inferred from the file extension, so program_cc.ts produces an MPEG transport stream and program_cc.mp4 an ISO base media file carrying the identical in-band captions.
has_embedded_captions closes the silent-failure gap. An injection that runs cleanly but drops the captions — because the SCC framerate was misread, or the build lacks -a53cc support — exits zero and produces a plausible file, so the wrapper treats the absence of a caption service on the video stream as a hard failure. decode_roundtrip goes one step further and decodes the embedded bytes with an independent tool (ccextractor), returning text you can diff against the source; a clean round-trip proves the parity, field assignment, and timing survived the mux. This is the same verification discipline the parent embedding CEA-608 & CEA-708 in transport streams reference applies to hand-built cc_data.
Threshold reference table
| Parameter | Value | Source / clause |
|---|---|---|
| SCC caption unit | 2 bytes / field / frame | ATSC A/53 Part 4 cc_data() |
| NTSC framerate / dropframe | 29.97 fps, drop-frame ; |
SMPTE ST 12-1 |
| PAL framerate | 25 fps, non-drop : |
EBU / SMPTE ST 12-1 |
| A53 side data type | AV_FRAME_DATA_A53_CC |
FFmpeg frame side data |
-a53cc flag |
1 (emit caption user data) |
FFmpeg libx264 / mpeg2video |
| Video codec on inject | re-encode required | A53 side data is encoder-written |
| Video codec on remux | -c:v copy preserves captions |
in-band 608/708 in the video ES |
| ffprobe caption probe | stream_disposition=captions |
FFmpeg stream disposition |
Edge cases & known gotchas
- Drop-frame vs non-drop: an SCC authored drop-frame (
HH:MM:SS;FF) but injected as non-drop injects ~3.6 s/hour of phantom offset, so captions drift late over a long program. Confirm the source timebase and match it; do not let FFmpeg assume 30 fps for a 29.97 stream. - 25 fps PAL: SCC and CEA-608 are NTSC-native. For a 25 fps source the caption timing must be resampled to the PAL frame grid before injection, or the byte pairs land on the wrong pictures — pass a non-drop timebase and verify against the actual video framerate, never the SCC’s assumed one.
- Closed vs open captions: this recipe produces closed captions the viewer can toggle. Burning captions into the picture with the
subtitles/drawtextfilters is a different, irreversible operation and defeats the point of in-band carriage — never substitute one for the other. - Stream copy vs re-encode: only the initial injection needs
-c:v libx264. Once the captions are in-band, every later container change (TS↔MP4, re-segmentation) must use-c:v copyso the caption ES rides along untouched; a second re-encode is pure generation loss. - Encoding-corrupt SCC: a mojibake or BOM-prefixed SCC header makes the
sccdemuxer skip lines and silently under-inject; normalize the file first per fixing UTF-8 encoding errors in SCC files.
Integration hook
This wrapper is the FFmpeg-driven implementation of the mux stage described in embedding CEA-608 & CEA-708 in transport streams: that page builds the cc_data triples and DTVCC packets by hand and explains the byte layout, while this one lets FFmpeg’s scc demuxer and -a53cc encoder path do the lowering from a finished SCC. Use the hand-built path when you need bit-level control over service numbers and interleave; use this recipe when you have a validated SCC and need it in a container fast, without generation loss on the picture. For the serial-digital equivalent — carrying the same cue bytes as ancillary data rather than picture user data — see mapping caption cues to VANC packets.
Related
- Embedding CEA-608 & CEA-708 in transport streams — the parent reference: cc_data triples, DTVCC packets and the caption_service_descriptor.
- Mapping caption cues to VANC packets — the SDI ancillary-data carriage path for the same cue bytes (DID 0x61).
- Fixing UTF-8 encoding errors in SCC files — normalizing an SCC before FFmpeg’s demuxer under-injects it.
Part of: Caption Muxing, Packaging & Delivery — the reference track for carrying, packaging and delivering caption data across broadcast and OTT.