Ffmpeg captions
Closed captions CEA-608/708 with ffmpeg: -a53cc 1 passthrough, readeia608 extraction, SCC and MCC and STL import/export, ccextractor handoff, 608 → SRT conversion, burning 608 to picture, preserving captions in MPEG-TS and MP4 and MOV. Use when the user asks to extract CEA-608 captions, preserve 708 captions, convert SCC to SRT, burn broadcast captions into video, pass through captions during transcode, or extract closed captions from a ProRes/MXF/TS file.From its SKILL.md
npx -y skills add damionrashford/media-os --skill ffmpeg-captionsAssembled from the repository path, not quoted from the project. Check it against their README if it does not work.
One thing to look at
- 17 stars17 stars. Stars are a popularity signal and not a quality one, but at this level it is likely that nobody has read this closely except its author, and you would be relying on your own review.
SKILL.md
11.0 KB, as published. Nobody here has run it
Ffmpeg Captions
Context: $ARGUMENTS
CEA-608/708 closed captions are carried inside the H.264 video stream (A53 SEI NAL units) or in sidecar SCC/MCC/STL files. They are NOT subtitle tracks. For subtitle-file workflows (SRT/ASS/VTT), use the ffmpeg-subtitles skill.
Quick start
- Preserve captions during H.264 re-encode: → Step 3,
-a53cc 1recipe - Check whether a file has embedded 608/708: → Step 1, detect
- Extract embedded CC to SRT: → Step 3,
ccextractorrecipe - Remux without losing captions: → Step 3, passthrough recipe
- Burn captions into the picture: → Step 3, extract then hand off to
ffmpeg-subtitles
When to use
- Source is MPEG-TS, MP4, MOV, or MXF with embedded 608/708 (broadcast, ATSC, FCC-compliant deliverables).
- You have a sidecar
.scc,.mcc, or.stland need to mux or convert it. - You are re-encoding H.264 and must not lose the SEI caption data.
- You need a plain
.srtfrom 608/708 for downstream tools.
Step 1 — Identify caption location
Captions can live in one of five places. Check all of them:
- Embedded SEI A53_CC in H.264 video (MPEG-TS, MP4, MOV, MXF) — invisible to
ffprobe -show_streams. - Dedicated c608/c708 track in MOV/MP4 — appears in
ffprobeascodec_name=eia_608orclosed_caption. - SCC sidecar file (
.scc) — timecoded hex byte pairs, NTSC drop-frame usually. - MCC sidecar file (
.mcc) — MacCaption, SCC's modern descendant with explicit frame-rate header. - STL sidecar file (
.stl) — EBU 3264, European broadcast. - Open (burned-in) captions in the picture on NTSC line 21 — read via
readeia608filter.
To detect embedded SEI captions:
ffprobe -loglevel error -select_streams v:0 -show_entries frame=side_data_list \
-of default=noprint_wrappers=1 in.ts | grep -i a53 | head -5
If nothing is returned, try the filter path (picture line 21):
ffmpeg -hide_banner -i in.mov -vf "readeia608,metadata=mode=print" -an -f null - 2>&1 | head -40
Or hand off to the external detector:
ccextractor in.mov --stdout > /dev/null # exits nonzero if no CC found
Step 2 — Pick the operation
| Goal | Operation | Command |
|---|---|---|
| Keep captions when transcoding H.264 | preserve | -a53cc 1 on libx264 |
| Keep captions when remuxing (no re-encode) | passthrough | -c copy |
Get a .srt from embedded 608/708 | extract | ccextractor |
Get a .srt from .scc / .mcc | convert | ccextractor |
| Burn captions into the picture | burn | extract to SRT, then ffmpeg-subtitles |
| Import SCC as a subtitle stream into TS | mux | -f scc -i ... -c:s copy |
Step 3 — Run the command
Preserve embedded 608 during H.264 re-encode
ffmpeg -i in.ts -c:v libx264 -crf 20 -a53cc 1 -c:a copy out.ts
Without -a53cc 1, libx264 silently discards SEI CC data. This flag only preserves what the source already has — it does not create captions.
Passthrough remux (fastest, lossless)
ffmpeg -i in.ts -c copy -map 0 out.mp4
Copying the video stream preserves the A53 SEI NAL units automatically. MPEG-TS → MP4 and MP4 → MOV generally survive; verify with Step 4.
Extract 608/708 to SRT via ccextractor
ccextractor in.ts -o out.srt
ccextractor in.mov -out=srt -o out.srt # explicit
ccextractor in.ts -o out.vtt -out=webvtt # VTT instead
ccextractor in.mcc -o out.srt # MCC → SRT
ccextractor in.scc -o out.srt # SCC → SRT
Install on macOS: brew install ccextractor. On Debian/Ubuntu: apt install ccextractor.
readeia608 filter (open captions on line 21 of NTSC)
ffmpeg -i in.mov -vf "readeia608,metadata=mode=print:file=captions.txt" \
-an -f null -
This only works when 608 bytes are drawn into the picture on lines 0–1 (legacy NTSC line 21). It does NOT read SEI-embedded CC. Output is frame-level metadata (lavfi.readeia608.0.cc, .sub, etc.) which you then parse into SRT yourself.
Import SCC sidecar as a subtitle stream
ffmpeg -i video.mp4 -f scc -i captions.scc \
-c:v copy -c:a copy -c:s copy -map 0:v -map 0:a -map 1 out.mp4
SCC demuxer support depends on ffmpeg build; verify with ffmpeg -formats | grep -i scc. If missing, fall back to ccextractor round-trip: SCC → SRT → mux as soft subtitle (see ffmpeg-subtitles).
Burn broadcast captions into the picture
Two-step: extract to SRT, then hand off.
ccextractor in.ts -o captions.srt
# now use the ffmpeg-subtitles skill:
ffmpeg -i in.ts -vf "subtitles=captions.srt" -c:a copy out.mp4
Step 4 — Verify captions survived
# 1. Probe for A53 side data on first video frames
ffprobe -loglevel error -select_streams v:0 \
-show_entries frame=side_data_list -read_intervals "%+#20" out.ts \
| grep -i a53
# 2. Decode and re-read via readeia608 (after on-screen render)
ffmpeg -i out.ts -vf "readeia608,metadata=mode=print" -an -f null - 2>&1 \
| grep -i 'lavfi.readeia608' | head
# 3. External sanity check
ccextractor out.ts -o /tmp/verify.srt && wc -l /tmp/verify.srt
If all three return empty, captions were dropped.
Available scripts
scripts/cc.py— detect / preserve / extract / passthrough helper around ffmpeg + ccextractor. Subcommands:detect,preserve,extract,passthrough.--dry-run,--verbose.
uv run ${CLAUDE_SKILL_DIR}/scripts/cc.py detect --input in.ts
uv run ${CLAUDE_SKILL_DIR}/scripts/cc.py preserve --input in.ts --output out.ts --crf 20
uv run ${CLAUDE_SKILL_DIR}/scripts/cc.py extract --input in.ts --output captions.srt
uv run ${CLAUDE_SKILL_DIR}/scripts/cc.py passthrough --input in.ts --output out.mp4
Reference docs
- Read
references/formats.mdfor CEA-608 vs 708 protocol differences, per-container carriage matrix, SCC / MCC / STL file layouts, ccextractor flag reference, SEI A53 NAL details, and the verification checklist.
Gotchas
- Embedded captions are invisible to most tools. 608/708 live as SEI NAL units inside the video bitstream — they are not a separate subtitle track, do not appear in
ffprobe -show_streams, and tools unaware of SEI will silently ignore them. -a53cc 1does NOT create captions. It only preserves existing SEI CC data during libx264 encode. Source must already have them.- Re-encoding without
-a53cc 1silently drops captions. No warning, no error. Always set the flag when re-encoding H.264 broadcast source. -c:v copypreserves SEI automatically because it copies the raw NAL units. Prefer passthrough when possible.- MPEG-TS preserves 608 natively inside the video PID. Round-tripping through TS is the safest transport.
- MP4 has two caption carriage modes: SEI passthrough (via
-a53cc 1) or a dedicatedc608/c708track. They are different; check which one your target player expects. - MOV (QuickTime) uses
c608/c708track types — FCP, Resolve, Adobe Media Encoder expect this, NOT SEI. - ccextractor is a separate tool, not bundled with ffmpeg. Install with
brew install ccextractor/apt install ccextractor. readeia608reads lines 0–1 of the DECODED picture, NOT SEI or side-data. Only useful for open/burned captions on NTSC line 21.- 708 supersedes 608 (UTF-16 vs 7-bit, more colors, positioning, languages). Most broadcast content carries both for backward compatibility.
- 608 has 4 channels (CC1–CC4) plus text (T1–T4). CC1 is primary English; CC3 is often Spanish. Specify with
ccextractor --cc=1. - 608 → modern formats is lossy. Roll-up, pop-on, paint-on modes, per-character colors, and precise positioning all flatten to plain SRT.
- SCC files are timecoded to the video's SMPTE timecode (usually 29.97 drop-frame). Mismatched timecode = shifted captions.
- MCC has an explicit frame-rate header; SCC assumes 29.97 DF. Never rename
.mccto.scc. - STL (EBU 3264) is European broadcast — binary, not ASCII. Requires dedicated parsers.
- Some MP4 muxers strip SEI NAL units even with
-c:v copy. Verify with Step 4 after every transcode. -bsf:v filter_units=...can accidentally strip SEI. Avoid when captions must survive.
Examples
Example 1: Comcast deliverable, re-encode to h264 while keeping captions
ffmpeg -i master.mxf -c:v libx264 -crf 18 -preset slow -a53cc 1 \
-c:a aac -b:a 192k -ac 2 out.ts
ccextractor out.ts -o /tmp/verify.srt # confirm CC1 present
Example 2: Convert SCC sidecar to SRT for a web player
ccextractor broadcast.scc -o broadcast.srt
Example 3: Burn CC1 into a social-media cut
ccextractor in.ts --cc=1 -o cc1.srt
ffmpeg -i in.ts -vf "subtitles=cc1.srt:force_style='FontSize=28'" \
-c:a copy social.mp4
Example 4: Remux TS → MP4 without losing captions
ffmpeg -i broadcast.ts -c copy -movflags +faststart broadcast.mp4
ffprobe -loglevel error -select_streams v:0 \
-show_entries frame=side_data_list -read_intervals "%+#10" broadcast.mp4 \
| grep -i a53
Troubleshooting
Error: "No closed captions found" (ccextractor exits nonzero)
Cause: source has no embedded CC, or they are in a c608 track not SEI.
Solution: run ffprobe -show_streams in.mov | grep -i eia_608 to check for a dedicated CC track. If present, extract with ffmpeg -i in.mov -map 0:s -c:s copy out.srt — try ffmpeg-subtitles skill for the mux. If neither, the file truly has no captions.
Captions disappeared after ffmpeg -c copy
Cause: the target muxer stripped SEI NAL units (some MP4 builds, WebM always).
Solution: mux to MPEG-TS instead, or re-encode with -c:v libx264 -a53cc 1. WebM/Matroska cannot carry 608/708 SEI — convert to a separate subtitle track.
Captions shift by ~2 seconds after SCC → SRT
Cause: SCC timecode is drop-frame (29.97), tool interpreted as non-drop (30).
Solution: pass --ucla to ccextractor or explicitly set -ff=30000/1001 on the video side. MCC files avoid this because they carry a frame-rate header.
readeia608 returns no output on broadcast capture
Cause: captions are in SEI, not drawn on picture line 21.
Solution: use ccextractor instead. readeia608 only reads pixels from decoded video, not bitstream metadata.
-a53cc 1 rejected: "Option not found"
Cause: your libx264 build is too old, or you applied the flag to a non-libx264 encoder.
Solution: verify with ffmpeg -h encoder=libx264 | grep a53cc. Upgrade ffmpeg if missing. HEVC/AV1 have separate mechanisms (hevc: -udu_sei 1 for SEI passthrough; AV1: -enable-cdef etc.).